Initial commit
This commit is contained in:
26
fusion_payroll/models/__init__.py
Normal file
26
fusion_payroll/models/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import tax_yearly_rates
|
||||
from . import tax_yearly_rate_line
|
||||
from . import hr_employee
|
||||
from . import hr_payslip
|
||||
from . import hr_roe
|
||||
from . import hr_tax_remittance
|
||||
from . import hr_payroll_t4
|
||||
from . import hr_payroll_t4a
|
||||
from . import pdf_field_position
|
||||
from . import payroll_report
|
||||
from . import payroll_report_paycheque
|
||||
from . import payroll_report_tax
|
||||
from . import payroll_report_summary
|
||||
from . import payroll_report_employee
|
||||
from . import payroll_report_cost
|
||||
from . import payroll_config_settings
|
||||
from . import payroll_work_location
|
||||
from . import payroll_tax_payment_schedule
|
||||
from . import payroll_accounting_mapping
|
||||
from . import pay_period
|
||||
from . import payroll_entry
|
||||
from . import payroll_dashboard
|
||||
from . import payroll_cheque
|
||||
from . import cheque_layout_settings
|
||||
506
fusion_payroll/models/cheque_layout_settings.py
Normal file
506
fusion_payroll/models/cheque_layout_settings.py
Normal file
@@ -0,0 +1,506 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Cheque Layout Settings
|
||||
======================
|
||||
Configurable cheque layout with visual preview.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
import base64
|
||||
|
||||
|
||||
class ChequeLayoutSettings(models.Model):
|
||||
"""
|
||||
Cheque Layout Settings with XY positioning for all fields.
|
||||
Includes image upload for visual preview alignment.
|
||||
"""
|
||||
_name = 'cheque.layout.settings'
|
||||
_description = 'Cheque Layout Settings'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Layout Name', required=True, default='Default Layout')
|
||||
active = fields.Boolean(default=True)
|
||||
is_default = fields.Boolean(string='Default Layout', default=False)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
required=True,
|
||||
)
|
||||
|
||||
# Background Image for Preview
|
||||
cheque_image = fields.Binary(
|
||||
string='Cheque Background Image',
|
||||
help='Upload your pre-printed cheque stock image for alignment preview',
|
||||
)
|
||||
cheque_image_filename = fields.Char(string='Cheque Image Filename')
|
||||
|
||||
# =============== PAGE SETTINGS ===============
|
||||
page_width = fields.Float(string='Page Width (inches)', default=8.5)
|
||||
page_height = fields.Float(string='Page Height (inches)', default=11.0)
|
||||
|
||||
# =============== SECTION HEIGHTS ===============
|
||||
# All values in inches from top of page
|
||||
section1_height = fields.Float(
|
||||
string='Cheque Section Height (in)',
|
||||
default=3.67,
|
||||
help='Height of the cheque section (top section)',
|
||||
)
|
||||
section2_start = fields.Float(
|
||||
string='Stub 1 Start Position (in)',
|
||||
default=3.67,
|
||||
help='Y position where stub 1 begins',
|
||||
)
|
||||
section2_height = fields.Float(
|
||||
string='Stub 1 Height (in)',
|
||||
default=3.67,
|
||||
help='Height of stub 1 (middle section)',
|
||||
)
|
||||
section3_start = fields.Float(
|
||||
string='Stub 2 Start Position (in)',
|
||||
default=7.34,
|
||||
help='Y position where stub 2 begins',
|
||||
)
|
||||
section3_height = fields.Float(
|
||||
string='Stub 2 Height (in)',
|
||||
default=3.66,
|
||||
help='Height of stub 2 (bottom section)',
|
||||
)
|
||||
|
||||
# =============== CHEQUE SECTION POSITIONS ===============
|
||||
# Date
|
||||
date_x = fields.Float(string='Date X (in)', default=6.8)
|
||||
date_y = fields.Float(string='Date Y (in)', default=0.35)
|
||||
date_font_size = fields.Integer(string='Date Font Size (pt)', default=11)
|
||||
|
||||
# Amount (numeric)
|
||||
amount_x = fields.Float(string='Amount X (in)', default=6.9)
|
||||
amount_y = fields.Float(string='Amount Y (in)', default=0.75)
|
||||
amount_font_size = fields.Integer(string='Amount Font Size (pt)', default=12)
|
||||
|
||||
# Amount in Words
|
||||
amount_words_x = fields.Float(string='Amount Words X (in)', default=0.6)
|
||||
amount_words_y = fields.Float(string='Amount Words Y (in)', default=1.25)
|
||||
amount_words_font_size = fields.Integer(string='Amount Words Font Size (pt)', default=9)
|
||||
|
||||
# Payee Name
|
||||
payee_x = fields.Float(string='Payee Name X (in)', default=0.6)
|
||||
payee_y = fields.Float(string='Payee Name Y (in)', default=1.65)
|
||||
payee_font_size = fields.Integer(string='Payee Font Size (pt)', default=11)
|
||||
|
||||
# Payee Address (separate from name)
|
||||
payee_address_x = fields.Float(string='Payee Address X (in)', default=0.6)
|
||||
payee_address_y = fields.Float(string='Payee Address Y (in)', default=1.85)
|
||||
payee_address_font_size = fields.Integer(string='Payee Address Font Size (pt)', default=9)
|
||||
|
||||
# Pay Period (on cheque)
|
||||
cheque_pay_period_x = fields.Float(string='Pay Period X (in)', default=0.6)
|
||||
cheque_pay_period_y = fields.Float(string='Pay Period Y (in)', default=2.1)
|
||||
|
||||
# Memo
|
||||
memo_x = fields.Float(string='Memo X (in)', default=0.6)
|
||||
memo_y = fields.Float(string='Memo Y (in)', default=3.1)
|
||||
memo_font_size = fields.Integer(string='Memo Font Size (pt)', default=8)
|
||||
|
||||
# =============== STUB SECTION OFFSETS ===============
|
||||
# These are relative to the section start position
|
||||
stub_padding_top = fields.Float(string='Stub Top Padding (in)', default=0.35)
|
||||
stub_padding_left = fields.Float(string='Stub Left Padding (in)', default=0.1)
|
||||
stub_padding_right = fields.Float(string='Stub Right Padding (in)', default=0.1)
|
||||
stub_padding_bottom = fields.Float(string='Stub Bottom Padding (in)', default=0.1)
|
||||
stub_full_width = fields.Boolean(string='Use Full Page Width', default=True, help='Stub content uses full page width')
|
||||
stub_center_data = fields.Boolean(string='Center Data in Stub', default=True, help='Center the stub data horizontally')
|
||||
stub_content_margin = fields.Float(string='Stub Content Margin (in)', default=0.5, help='Left and right margin for stub content when centering. Increase to push content more toward center.')
|
||||
|
||||
# Column widths (as percentages)
|
||||
col1_width = fields.Integer(string='Column 1 Width (%)', default=22, help='Employee/Company Info')
|
||||
col2_width = fields.Integer(string='Column 2 Width (%)', default=26, help='PAY/Benefits')
|
||||
col3_width = fields.Integer(string='Column 3 Width (%)', default=26, help='Taxes/Deductions')
|
||||
col4_width = fields.Integer(string='Column 4 Width (%)', default=26, help='Summary')
|
||||
|
||||
# Font sizes for stubs
|
||||
stub_title_font_size = fields.Integer(string='Stub Title Font Size', default=9)
|
||||
stub_content_font_size = fields.Integer(string='Stub Content Font Size', default=7)
|
||||
stub_header_font_size = fields.Integer(string='Stub Header Font Size', default=6)
|
||||
|
||||
# =============== METHODS ===============
|
||||
|
||||
@api.model
|
||||
def get_default_settings(self):
|
||||
"""Get or create the default layout settings for the current company."""
|
||||
settings = self.search([
|
||||
('company_id', '=', self.env.company.id),
|
||||
('is_default', '=', True),
|
||||
], limit=1)
|
||||
|
||||
if not settings:
|
||||
settings = self.search([
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not settings:
|
||||
settings = self.create({
|
||||
'name': 'Default Cheque Layout',
|
||||
'company_id': self.env.company.id,
|
||||
'is_default': True,
|
||||
})
|
||||
|
||||
return settings
|
||||
|
||||
def action_set_as_default(self):
|
||||
"""Set this layout as the default for the company."""
|
||||
# Unset other defaults
|
||||
self.search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('is_default', '=', True),
|
||||
('id', '!=', self.id),
|
||||
]).write({'is_default': False})
|
||||
|
||||
self.is_default = True
|
||||
|
||||
def get_layout_data(self):
|
||||
"""Return all layout settings as a dictionary for the report template."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'page': {
|
||||
'width': self.page_width,
|
||||
'height': self.page_height,
|
||||
},
|
||||
'sections': {
|
||||
'cheque': {
|
||||
'height': self.section1_height,
|
||||
},
|
||||
'stub1': {
|
||||
'start': self.section2_start,
|
||||
'height': self.section2_height,
|
||||
},
|
||||
'stub2': {
|
||||
'start': self.section3_start,
|
||||
'height': self.section3_height,
|
||||
},
|
||||
},
|
||||
'cheque': {
|
||||
'date': {'x': self.date_x, 'y': self.date_y, 'font_size': self.date_font_size},
|
||||
'amount': {'x': self.amount_x, 'y': self.amount_y, 'font_size': self.amount_font_size},
|
||||
'amount_words': {'x': self.amount_words_x, 'y': self.amount_words_y, 'font_size': self.amount_words_font_size},
|
||||
'payee': {'x': self.payee_x, 'y': self.payee_y, 'font_size': self.payee_font_size},
|
||||
'pay_period': {'x': self.cheque_pay_period_x, 'y': self.cheque_pay_period_y},
|
||||
'memo': {'x': self.memo_x, 'y': self.memo_y, 'font_size': self.memo_font_size},
|
||||
},
|
||||
'stub': {
|
||||
'padding': {
|
||||
'top': self.stub_padding_top,
|
||||
'left': self.stub_padding_left,
|
||||
'right': self.stub_padding_right,
|
||||
'bottom': self.stub_padding_bottom,
|
||||
},
|
||||
'columns': {
|
||||
'col1': self.col1_width,
|
||||
'col2': self.col2_width,
|
||||
'col3': self.col3_width,
|
||||
'col4': self.col4_width,
|
||||
},
|
||||
'fonts': {
|
||||
'title': self.stub_title_font_size,
|
||||
'content': self.stub_content_font_size,
|
||||
'header': self.stub_header_font_size,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def action_open_preview(self):
|
||||
"""Open the visual preview configuration wizard."""
|
||||
self.ensure_one()
|
||||
# Ensure the record is saved to database (required for related fields to work)
|
||||
# Check if it's a real database ID (integer) vs a NewId
|
||||
if not isinstance(self.id, int):
|
||||
# Record not yet saved - need to save first
|
||||
raise UserError(_('Please save the record first before opening the preview.'))
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Cheque Layout Preview'),
|
||||
'res_model': 'cheque.layout.preview.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_settings_id': self.id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ChequeLayoutPreviewWizard(models.TransientModel):
|
||||
"""
|
||||
Wizard for visual preview of cheque layout.
|
||||
Shows the cheque image with overlaid field positions.
|
||||
"""
|
||||
_name = 'cheque.layout.preview.wizard'
|
||||
_description = 'Cheque Layout Preview'
|
||||
|
||||
settings_id = fields.Many2one('cheque.layout.settings', string='Layout Settings', required=True)
|
||||
|
||||
# Local copies of fields for editing (will be saved back to settings on close)
|
||||
cheque_image = fields.Binary(string='Cheque Background Image')
|
||||
cheque_image_filename = fields.Char(string='Cheque Image Filename')
|
||||
|
||||
# Section positions
|
||||
section1_height = fields.Float(string='Cheque End (in)', default=3.67)
|
||||
section2_start = fields.Float(string='Stub 1 Start (in)', default=3.67)
|
||||
section3_start = fields.Float(string='Stub 2 Start (in)', default=7.34)
|
||||
|
||||
# Cheque field positions
|
||||
date_x = fields.Float(string='Date X', default=6.8)
|
||||
date_y = fields.Float(string='Date Y', default=0.35)
|
||||
amount_x = fields.Float(string='Amount X', default=6.9)
|
||||
amount_y = fields.Float(string='Amount Y', default=0.75)
|
||||
amount_words_x = fields.Float(string='Amount Words X', default=0.6)
|
||||
amount_words_y = fields.Float(string='Amount Words Y', default=1.25)
|
||||
payee_x = fields.Float(string='Payee X', default=0.6)
|
||||
payee_y = fields.Float(string='Payee Y', default=1.65)
|
||||
payee_address_x = fields.Float(string='Payee Address X', default=0.6)
|
||||
payee_address_y = fields.Float(string='Payee Address Y', default=1.85)
|
||||
cheque_pay_period_x = fields.Float(string='Pay Period X', default=0.6)
|
||||
cheque_pay_period_y = fields.Float(string='Pay Period Y', default=2.1)
|
||||
memo_x = fields.Float(string='Memo X', default=0.6)
|
||||
memo_y = fields.Float(string='Memo Y', default=3.1)
|
||||
|
||||
# Stub positions
|
||||
stub_padding_top = fields.Float(string='Stub Top Padding', default=0.35)
|
||||
stub_padding_left = fields.Float(string='Stub Left Padding', default=0.1)
|
||||
stub_content_margin = fields.Float(string='Stub Content Margin (in)', default=0.5)
|
||||
stub_center_data = fields.Boolean(string='Center Data in Stub', default=True)
|
||||
|
||||
# Preview HTML (computed)
|
||||
preview_html = fields.Html(string='Preview', compute='_compute_preview_html', sanitize=False)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Load values from settings_id when wizard is opened."""
|
||||
res = super().default_get(fields_list)
|
||||
settings_id = self.env.context.get('default_settings_id')
|
||||
if settings_id:
|
||||
settings = self.env['cheque.layout.settings'].browse(settings_id)
|
||||
if settings.exists():
|
||||
res.update({
|
||||
'cheque_image': settings.cheque_image,
|
||||
'cheque_image_filename': settings.cheque_image_filename,
|
||||
'section1_height': settings.section1_height,
|
||||
'section2_start': settings.section2_start,
|
||||
'section3_start': settings.section3_start,
|
||||
'date_x': settings.date_x,
|
||||
'date_y': settings.date_y,
|
||||
'amount_x': settings.amount_x,
|
||||
'amount_y': settings.amount_y,
|
||||
'amount_words_x': settings.amount_words_x,
|
||||
'amount_words_y': settings.amount_words_y,
|
||||
'payee_x': settings.payee_x,
|
||||
'payee_y': settings.payee_y,
|
||||
'payee_address_x': settings.payee_address_x,
|
||||
'payee_address_y': settings.payee_address_y,
|
||||
'cheque_pay_period_x': settings.cheque_pay_period_x,
|
||||
'cheque_pay_period_y': settings.cheque_pay_period_y,
|
||||
'memo_x': settings.memo_x,
|
||||
'memo_y': settings.memo_y,
|
||||
'stub_padding_top': settings.stub_padding_top,
|
||||
'stub_padding_left': settings.stub_padding_left,
|
||||
'stub_content_margin': settings.stub_content_margin,
|
||||
'stub_center_data': settings.stub_center_data,
|
||||
})
|
||||
return res
|
||||
|
||||
@api.depends(
|
||||
'cheque_image', 'section1_height', 'section2_start', 'section3_start',
|
||||
'date_x', 'date_y', 'amount_x', 'amount_y', 'amount_words_x', 'amount_words_y',
|
||||
'payee_x', 'payee_y', 'payee_address_x', 'payee_address_y',
|
||||
'cheque_pay_period_x', 'cheque_pay_period_y',
|
||||
'memo_x', 'memo_y', 'stub_padding_top', 'stub_padding_left'
|
||||
)
|
||||
def _compute_preview_html(self):
|
||||
"""Generate HTML preview with overlaid positions."""
|
||||
for wizard in self:
|
||||
# Scale factor: 1 inch = 72 pixels for preview
|
||||
scale = 72
|
||||
page_width = 8.5 * scale
|
||||
page_height = 11 * scale
|
||||
|
||||
# Build background image style
|
||||
bg_style = ''
|
||||
if wizard.cheque_image:
|
||||
image_data = wizard.cheque_image.decode('utf-8') if isinstance(wizard.cheque_image, bytes) else wizard.cheque_image
|
||||
bg_style = f'background-image: url(data:image/png;base64,{image_data}); background-size: 100% 100%;'
|
||||
|
||||
# Helper to convert inches to pixels
|
||||
def px(inches):
|
||||
return (inches or 0) * scale
|
||||
|
||||
# Section divider lines
|
||||
section_lines = f'''
|
||||
<div style="position: absolute; top: {px(wizard.section1_height)}px; left: 0; right: 0;
|
||||
border-bottom: 2px dashed red; z-index: 10;">
|
||||
<span style="background: red; color: white; font-size: 10px; padding: 2px 4px;">
|
||||
Cheque End ({wizard.section1_height:.2f} in)
|
||||
</span>
|
||||
</div>
|
||||
<div style="position: absolute; top: {px(wizard.section3_start)}px; left: 0; right: 0;
|
||||
border-bottom: 2px dashed red; z-index: 10;">
|
||||
<span style="background: red; color: white; font-size: 10px; padding: 2px 4px;">
|
||||
Stub 2 Start ({wizard.section3_start:.2f} in)
|
||||
</span>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# Field markers (cheque section)
|
||||
field_markers = f'''
|
||||
<!-- Date -->
|
||||
<div style="position: absolute; left: {px(wizard.date_x)}px; top: {px(wizard.date_y)}px;
|
||||
background: rgba(0,128,255,0.9); color: white; padding: 2px 6px; font-size: 10px;
|
||||
border: 1px solid blue; z-index: 20; white-space: nowrap;">
|
||||
DATE: JAN 11, 2026
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div style="position: absolute; left: {px(wizard.amount_x)}px; top: {px(wizard.amount_y)}px;
|
||||
background: rgba(0,180,0,0.9); color: white; padding: 2px 6px; font-size: 10px;
|
||||
border: 1px solid green; z-index: 20; white-space: nowrap;">
|
||||
**1,465.19
|
||||
</div>
|
||||
|
||||
<!-- Amount in Words -->
|
||||
<div style="position: absolute; left: {px(wizard.amount_words_x)}px; top: {px(wizard.amount_words_y)}px;
|
||||
background: rgba(255,128,0,0.9); color: white; padding: 2px 6px; font-size: 9px;
|
||||
border: 1px solid orange; z-index: 20; white-space: nowrap;">
|
||||
AMOUNT IN WORDS
|
||||
</div>
|
||||
|
||||
<!-- Payee Name -->
|
||||
<div style="position: absolute; left: {px(wizard.payee_x)}px; top: {px(wizard.payee_y)}px;
|
||||
background: rgba(128,0,255,0.9); color: white; padding: 2px 6px; font-size: 10px;
|
||||
border: 1px solid purple; z-index: 20; white-space: nowrap;">
|
||||
PAYEE NAME
|
||||
</div>
|
||||
|
||||
<!-- Payee Address -->
|
||||
<div style="position: absolute; left: {px(wizard.payee_address_x)}px; top: {px(wizard.payee_address_y)}px;
|
||||
background: rgba(180,0,180,0.9); color: white; padding: 2px 6px; font-size: 9px;
|
||||
border: 1px solid #b400b4; z-index: 20; white-space: nowrap;">
|
||||
PAYEE ADDRESS
|
||||
</div>
|
||||
|
||||
<!-- Pay Period -->
|
||||
<div style="position: absolute; left: {px(wizard.cheque_pay_period_x)}px; top: {px(wizard.cheque_pay_period_y)}px;
|
||||
background: rgba(255,0,128,0.9); color: white; padding: 2px 6px; font-size: 9px;
|
||||
border: 1px solid #ff0080; z-index: 20; white-space: nowrap;">
|
||||
PAY PERIOD
|
||||
</div>
|
||||
|
||||
<!-- Memo -->
|
||||
<div style="position: absolute; left: {px(wizard.memo_x)}px; top: {px(wizard.memo_y)}px;
|
||||
background: rgba(128,128,128,0.9); color: white; padding: 2px 6px; font-size: 9px;
|
||||
border: 1px solid gray; z-index: 20; white-space: nowrap;">
|
||||
MEMO
|
||||
</div>
|
||||
'''
|
||||
|
||||
# Stub markers - full width centered
|
||||
stub1_top = (wizard.section2_start or 0) + (wizard.stub_padding_top or 0)
|
||||
stub2_top = (wizard.section3_start or 0) + (wizard.stub_padding_top or 0)
|
||||
|
||||
stub_markers = f'''
|
||||
<!-- Stub 1 Content Area (Full Width, Centered) -->
|
||||
<div style="position: absolute; left: {px(wizard.stub_padding_left)}px; top: {px(stub1_top)}px;
|
||||
right: {px(wizard.stub_padding_left)}px; height: 200px;
|
||||
border: 2px dashed #00aa00; z-index: 15; display: flex; align-items: flex-start; justify-content: center;">
|
||||
<span style="background: #00aa00; color: white; font-size: 10px; padding: 2px 6px;">
|
||||
STUB 1 - FULL WIDTH CENTERED
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Stub 2 Content Area (Full Width, Centered) -->
|
||||
<div style="position: absolute; left: {px(wizard.stub_padding_left)}px; top: {px(stub2_top)}px;
|
||||
right: {px(wizard.stub_padding_left)}px; height: 200px;
|
||||
border: 2px dashed #0000aa; z-index: 15; display: flex; align-items: flex-start; justify-content: center;">
|
||||
<span style="background: #0000aa; color: white; font-size: 10px; padding: 2px 6px;">
|
||||
STUB 2 - FULL WIDTH CENTERED
|
||||
</span>
|
||||
</div>
|
||||
'''
|
||||
|
||||
wizard.preview_html = f'''
|
||||
<div style="position: relative; width: {page_width}px; height: {page_height}px;
|
||||
border: 1px solid #ccc; {bg_style} overflow: hidden; margin: 0 auto; background-color: #f8f8f8;">
|
||||
{section_lines}
|
||||
{field_markers}
|
||||
{stub_markers}
|
||||
</div>
|
||||
'''
|
||||
|
||||
def action_save_and_close(self):
|
||||
"""Save changes back to settings and close the wizard."""
|
||||
self.ensure_one()
|
||||
if self.settings_id:
|
||||
self.settings_id.write({
|
||||
'cheque_image': self.cheque_image,
|
||||
'cheque_image_filename': self.cheque_image_filename,
|
||||
'section1_height': self.section1_height,
|
||||
'section2_start': self.section2_start,
|
||||
'section3_start': self.section3_start,
|
||||
'date_x': self.date_x,
|
||||
'date_y': self.date_y,
|
||||
'amount_x': self.amount_x,
|
||||
'amount_y': self.amount_y,
|
||||
'amount_words_x': self.amount_words_x,
|
||||
'amount_words_y': self.amount_words_y,
|
||||
'payee_x': self.payee_x,
|
||||
'payee_y': self.payee_y,
|
||||
'payee_address_x': self.payee_address_x,
|
||||
'payee_address_y': self.payee_address_y,
|
||||
'cheque_pay_period_x': self.cheque_pay_period_x,
|
||||
'cheque_pay_period_y': self.cheque_pay_period_y,
|
||||
'memo_x': self.memo_x,
|
||||
'memo_y': self.memo_y,
|
||||
'stub_padding_top': self.stub_padding_top,
|
||||
'stub_padding_left': self.stub_padding_left,
|
||||
'stub_content_margin': self.stub_content_margin,
|
||||
'stub_center_data': self.stub_center_data,
|
||||
})
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def action_print_test(self):
|
||||
"""Save current wizard values and print a test cheque."""
|
||||
self.ensure_one()
|
||||
|
||||
# First, save current wizard values to the settings record
|
||||
if self.settings_id:
|
||||
self.settings_id.write({
|
||||
'cheque_image': self.cheque_image,
|
||||
'cheque_image_filename': self.cheque_image_filename,
|
||||
'section1_height': self.section1_height,
|
||||
'section2_start': self.section2_start,
|
||||
'section3_start': self.section3_start,
|
||||
'date_x': self.date_x,
|
||||
'date_y': self.date_y,
|
||||
'amount_x': self.amount_x,
|
||||
'amount_y': self.amount_y,
|
||||
'amount_words_x': self.amount_words_x,
|
||||
'amount_words_y': self.amount_words_y,
|
||||
'payee_x': self.payee_x,
|
||||
'payee_y': self.payee_y,
|
||||
'payee_address_x': self.payee_address_x,
|
||||
'payee_address_y': self.payee_address_y,
|
||||
'cheque_pay_period_x': self.cheque_pay_period_x,
|
||||
'cheque_pay_period_y': self.cheque_pay_period_y,
|
||||
'memo_x': self.memo_x,
|
||||
'memo_y': self.memo_y,
|
||||
'stub_padding_top': self.stub_padding_top,
|
||||
'stub_padding_left': self.stub_padding_left,
|
||||
'stub_content_margin': self.stub_content_margin,
|
||||
'stub_center_data': self.stub_center_data,
|
||||
})
|
||||
|
||||
# Find a sample cheque to print
|
||||
cheque = self.env['payroll.cheque'].search([], limit=1)
|
||||
if cheque:
|
||||
return self.env.ref('fusion_payroll.action_report_payroll_cheque').report_action(cheque)
|
||||
else:
|
||||
raise UserError(_('No cheque records found to print test.'))
|
||||
25
fusion_payroll/models/hr_contract.py
Normal file
25
fusion_payroll/models/hr_contract.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class HrContract(models.Model):
|
||||
_inherit = 'hr.contract'
|
||||
|
||||
# === Work Location ===
|
||||
work_location_id = fields.Many2one(
|
||||
'payroll.work.location',
|
||||
string='Primary Work Location',
|
||||
help='Primary work location for this contract (used for tax calculations)',
|
||||
domain="[('company_id', '=', company_id), ('status', '=', 'active')]",
|
||||
)
|
||||
|
||||
# === Canadian Tax Credits ===
|
||||
fed_tax_credit = fields.Float(
|
||||
string='Federal Tax Credit',
|
||||
help='Federal personal tax credit amount for the employee',
|
||||
)
|
||||
provincial_tax_credit = fields.Float(
|
||||
string='Provincial Tax Credit',
|
||||
help='Provincial personal tax credit amount for the employee',
|
||||
)
|
||||
468
fusion_payroll/models/hr_employee.py
Normal file
468
fusion_payroll/models/hr_employee.py
Normal file
@@ -0,0 +1,468 @@
|
||||
# -*- 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 '',
|
||||
},
|
||||
}
|
||||
1555
fusion_payroll/models/hr_payroll_t4.py
Normal file
1555
fusion_payroll/models/hr_payroll_t4.py
Normal file
File diff suppressed because it is too large
Load Diff
704
fusion_payroll/models/hr_payroll_t4a.py
Normal file
704
fusion_payroll/models/hr_payroll_t4a.py
Normal file
@@ -0,0 +1,704 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
import os
|
||||
import io
|
||||
from datetime import date
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
from odoo import tools
|
||||
|
||||
|
||||
class HrT4ASummary(models.Model):
|
||||
"""T4A Summary - One per company per tax year"""
|
||||
_name = 'hr.t4a.summary'
|
||||
_description = 'T4A Summary'
|
||||
_order = 'tax_year desc'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
def _get_pdf_text_coordinates(self):
|
||||
"""Get text overlay coordinates for flattened PDF
|
||||
Returns dict mapping field names to (x, y, font_size, font_name) tuples
|
||||
Coordinates are in points (1/72 inch), origin at bottom-left
|
||||
Reads from pdf.field.position model based on template type
|
||||
"""
|
||||
# Query configured positions from database for T4A Summary
|
||||
position_model = self.env['pdf.field.position']
|
||||
return position_model.get_coordinates_dict('T4A Summary')
|
||||
|
||||
STATE_SELECTION = [
|
||||
('draft', 'Draft'),
|
||||
('generated', 'Generated'),
|
||||
('filed', 'Filed'),
|
||||
]
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
compute='_compute_name',
|
||||
store=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
related='company_id.currency_id',
|
||||
)
|
||||
tax_year = fields.Integer(
|
||||
string='Tax Year',
|
||||
required=True,
|
||||
default=lambda self: date.today().year - 1,
|
||||
)
|
||||
state = fields.Selection(
|
||||
selection=STATE_SELECTION,
|
||||
string='Status',
|
||||
default='draft',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# === CRA Information ===
|
||||
cra_business_number = fields.Char(
|
||||
string='CRA Business Number',
|
||||
compute='_compute_cra_business_number',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_cra_business_number(self):
|
||||
"""Get CRA business number from payroll settings."""
|
||||
for summary in self:
|
||||
if summary.company_id:
|
||||
settings = self.env['payroll.config.settings'].get_settings(summary.company_id.id)
|
||||
summary.cra_business_number = settings.get_cra_payroll_account_number() or summary.company_id.vat or ''
|
||||
else:
|
||||
summary.cra_business_number = ''
|
||||
|
||||
# === Slip Count ===
|
||||
slip_count = fields.Integer(
|
||||
string='Total T4A Slips',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
)
|
||||
slip_ids = fields.One2many(
|
||||
'hr.t4a.slip',
|
||||
'summary_id',
|
||||
string='T4A Slips',
|
||||
)
|
||||
|
||||
# === Summary Totals ===
|
||||
total_box_016 = fields.Monetary(
|
||||
string='Total Box 016 (Pension)',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
)
|
||||
total_box_018 = fields.Monetary(
|
||||
string='Total Box 018 (Lump-Sum)',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
)
|
||||
total_box_020 = fields.Monetary(
|
||||
string='Total Box 020 (Commissions)',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
)
|
||||
total_box_024 = fields.Monetary(
|
||||
string='Total Box 024 (Annuities)',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
)
|
||||
total_box_048 = fields.Monetary(
|
||||
string='Total Box 048 (Fees)',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_totals',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# === Contact Information ===
|
||||
contact_name = fields.Char(
|
||||
string='Contact Person',
|
||||
default=lambda self: self.env.user.name,
|
||||
)
|
||||
contact_phone = fields.Char(
|
||||
string='Telephone',
|
||||
)
|
||||
|
||||
# === Filing Information ===
|
||||
filing_date = fields.Date(
|
||||
string='Filing Date',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
@api.depends('tax_year', 'company_id')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
rec.name = f"T4A Summary {rec.tax_year} - {rec.company_id.name}"
|
||||
|
||||
@api.depends('slip_ids')
|
||||
def _compute_totals(self):
|
||||
for rec in self:
|
||||
slips = rec.slip_ids
|
||||
rec.slip_count = len(slips)
|
||||
rec.total_box_016 = sum(slips.mapped('box_016_pension'))
|
||||
rec.total_box_018 = sum(slips.mapped('box_018_lump_sum'))
|
||||
rec.total_box_020 = sum(slips.mapped('box_020_commissions'))
|
||||
rec.total_box_024 = sum(slips.mapped('box_024_annuities'))
|
||||
rec.total_box_048 = sum(slips.mapped('box_048_fees'))
|
||||
|
||||
def action_mark_filed(self):
|
||||
"""Mark T4A Summary as filed"""
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'state': 'filed',
|
||||
'filing_date': date.today(),
|
||||
})
|
||||
|
||||
|
||||
class HrT4ASlip(models.Model):
|
||||
"""T4A Slip - One per recipient per tax year"""
|
||||
_name = 'hr.t4a.slip'
|
||||
_description = 'T4A Slip'
|
||||
_order = 'recipient_name'
|
||||
|
||||
def _get_pdf_text_coordinates(self):
|
||||
"""Get text overlay coordinates for flattened PDF
|
||||
Returns dict mapping field names to (x, y, font_size, font_name) tuples
|
||||
Coordinates are in points (1/72 inch), origin at bottom-left
|
||||
Reads from pdf.field.position model based on template type
|
||||
"""
|
||||
# Query configured positions from database for T4A
|
||||
position_model = self.env['pdf.field.position']
|
||||
return position_model.get_coordinates_dict('T4A')
|
||||
|
||||
def _overlay_text_on_pdf(self, template_path, field_mapping):
|
||||
"""Overlay text on a flattened PDF using reportlab
|
||||
Returns base64-encoded PDF data
|
||||
"""
|
||||
try:
|
||||
from reportlab.pdfgen import canvas
|
||||
from PyPDF2 import PdfReader, PdfWriter
|
||||
except ImportError as e:
|
||||
raise UserError(f'Required library not available: {str(e)}\nPlease install reportlab and PyPDF2.')
|
||||
|
||||
# Get text coordinates
|
||||
text_coords = self._get_pdf_text_coordinates()
|
||||
if not text_coords:
|
||||
raise UserError(
|
||||
'Text coordinates not configured for T4A template. '
|
||||
'Please configure PDF field positions in Payroll → Configuration → PDF Field Positions.'
|
||||
)
|
||||
|
||||
# Read the template PDF
|
||||
with open(template_path, 'rb') as template_file:
|
||||
template_reader = PdfReader(template_file)
|
||||
if not template_reader.pages:
|
||||
raise UserError('Template PDF has no pages')
|
||||
|
||||
# Get first page dimensions
|
||||
first_page = template_reader.pages[0]
|
||||
page_width = float(first_page.mediabox.width)
|
||||
page_height = float(first_page.mediabox.height)
|
||||
|
||||
# Create overlay PDF with text
|
||||
overlay_buffer = io.BytesIO()
|
||||
can = canvas.Canvas(overlay_buffer, pagesize=(page_width, page_height))
|
||||
|
||||
# Draw text for each field
|
||||
for field_name, value in field_mapping.items():
|
||||
if field_name in text_coords and value:
|
||||
coord_data = text_coords[field_name]
|
||||
# Handle both old format (x, y, font_size) and new format (x, y, font_size, font_name)
|
||||
if len(coord_data) == 4:
|
||||
x, y, font_size, font_name = coord_data
|
||||
elif len(coord_data) == 3:
|
||||
x, y, font_size = coord_data
|
||||
font_name = 'Helvetica' # Default font
|
||||
else:
|
||||
continue # Skip invalid coordinate data
|
||||
|
||||
can.setFont(font_name, font_size)
|
||||
can.drawString(x, y, str(value))
|
||||
|
||||
can.save()
|
||||
overlay_buffer.seek(0)
|
||||
|
||||
# Merge overlay with template
|
||||
with open(template_path, 'rb') as template_file:
|
||||
template_reader = PdfReader(template_file)
|
||||
overlay_reader = PdfReader(overlay_buffer)
|
||||
|
||||
writer = PdfWriter()
|
||||
for i, page in enumerate(template_reader.pages):
|
||||
if i < len(overlay_reader.pages):
|
||||
page.merge_page(overlay_reader.pages[i])
|
||||
writer.add_page(page)
|
||||
|
||||
# Write to bytes
|
||||
output_buffer = io.BytesIO()
|
||||
writer.write(output_buffer)
|
||||
return base64.b64encode(output_buffer.getvalue())
|
||||
|
||||
summary_id = fields.Many2one(
|
||||
'hr.t4a.summary',
|
||||
string='T4A Summary',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
related='summary_id.company_id',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
related='summary_id.currency_id',
|
||||
)
|
||||
tax_year = fields.Integer(
|
||||
related='summary_id.tax_year',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# === Recipient Information ===
|
||||
recipient_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Recipient',
|
||||
help='Recipient partner (individual or business)',
|
||||
)
|
||||
recipient_name = fields.Char(
|
||||
string='Recipient Name',
|
||||
required=True,
|
||||
help='Last name, first name and initials',
|
||||
)
|
||||
recipient_address = fields.Text(
|
||||
string='Recipient Address',
|
||||
help='Full address including province and postal code',
|
||||
)
|
||||
recipient_sin = fields.Char(
|
||||
string='SIN (Box 12)',
|
||||
help='Social Insurance Number (9 digits)',
|
||||
)
|
||||
recipient_account_number = fields.Char(
|
||||
string='Account Number (Box 13)',
|
||||
help='Business Number if recipient is a business',
|
||||
)
|
||||
|
||||
# === Income Boxes ===
|
||||
box_016_pension = fields.Monetary(
|
||||
string='Box 016: Pension or Superannuation',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
box_018_lump_sum = fields.Monetary(
|
||||
string='Box 018: Lump-Sum Payments',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
box_020_commissions = fields.Monetary(
|
||||
string='Box 020: Self-Employed Commissions',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
box_024_annuities = fields.Monetary(
|
||||
string='Box 024: Annuities',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
box_048_fees = fields.Monetary(
|
||||
string='Box 048: Fees for Services',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
|
||||
# === Other Information (028-197) ===
|
||||
other_info_ids = fields.One2many(
|
||||
'hr.t4a.other.info',
|
||||
'slip_id',
|
||||
string='Other Information',
|
||||
help='Other information boxes (028-197)',
|
||||
)
|
||||
|
||||
# === PDF Generation ===
|
||||
filled_pdf = fields.Binary(
|
||||
string='Filled PDF',
|
||||
attachment=True,
|
||||
)
|
||||
filled_pdf_filename = fields.Char(
|
||||
string='PDF Filename',
|
||||
)
|
||||
|
||||
@api.onchange('recipient_id')
|
||||
def _onchange_recipient_id(self):
|
||||
"""Auto-fill recipient information from partner"""
|
||||
if self.recipient_id:
|
||||
# Format name: Last name, First name
|
||||
name_parts = self.recipient_id.name.split(',') if ',' in self.recipient_id.name else self.recipient_id.name.split()
|
||||
if len(name_parts) >= 2:
|
||||
self.recipient_name = f"{name_parts[-1].strip()}, {' '.join(name_parts[:-1]).strip()}"
|
||||
else:
|
||||
self.recipient_name = self.recipient_id.name
|
||||
|
||||
# Build address
|
||||
address_parts = []
|
||||
if self.recipient_id.street:
|
||||
address_parts.append(self.recipient_id.street)
|
||||
if self.recipient_id.street2:
|
||||
address_parts.append(self.recipient_id.street2)
|
||||
if self.recipient_id.city:
|
||||
city_line = self.recipient_id.city
|
||||
if self.recipient_id.state_id:
|
||||
city_line += f", {self.recipient_id.state_id.code}"
|
||||
if self.recipient_id.zip:
|
||||
city_line += f" {self.recipient_id.zip}"
|
||||
address_parts.append(city_line)
|
||||
self.recipient_address = '\n'.join(address_parts)
|
||||
|
||||
# Get SIN if available (might be stored in a custom field)
|
||||
if hasattr(self.recipient_id, 'sin_number'):
|
||||
self.recipient_sin = self.recipient_id.sin_number
|
||||
|
||||
def action_fill_pdf(self):
|
||||
"""Fill the T4A PDF form with data from this slip"""
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
# Try to import pdfrw (preferred) or PyPDF2
|
||||
try:
|
||||
from pdfrw import PdfReader, PdfWriter
|
||||
use_pdfrw = True
|
||||
except ImportError:
|
||||
try:
|
||||
import PyPDF2
|
||||
use_pdfrw = False
|
||||
except ImportError:
|
||||
raise UserError(
|
||||
'PDF library not found. Please install pdfrw or PyPDF2:\n'
|
||||
'pip install pdfrw\n'
|
||||
'or\n'
|
||||
'pip install PyPDF2'
|
||||
)
|
||||
|
||||
# Get PDF template path - try multiple locations
|
||||
# 1. Try in static/pdf/ folder (recommended location)
|
||||
module_path = os.path.dirname(os.path.dirname(__file__))
|
||||
template_path = os.path.join(module_path, 'static', 'pdf', 't4a-fill-25e.pdf')
|
||||
|
||||
# 2. Try in module root directory (fallback)
|
||||
if not os.path.exists(template_path):
|
||||
template_path = os.path.join(module_path, 't4a-fill-25e.pdf')
|
||||
|
||||
# 3. Try using tools.file_path (Odoo 19)
|
||||
if not os.path.exists(template_path):
|
||||
try:
|
||||
template_path = tools.file_path('fusion_payroll/static/pdf/t4a-fill-25e.pdf')
|
||||
except:
|
||||
pass
|
||||
|
||||
# 4. Final fallback - root directory
|
||||
if not os.path.exists(template_path):
|
||||
try:
|
||||
template_path = tools.file_path('fusion_payroll/t4a-fill-25e.pdf')
|
||||
except:
|
||||
pass
|
||||
|
||||
if not os.path.exists(template_path):
|
||||
raise UserError(
|
||||
'T4A PDF template not found. Please ensure t4a-fill-25e.pdf is in one of these locations:\n'
|
||||
f'1. {os.path.join(module_path, "static", "pdf", "t4a-fill-25e.pdf")} (recommended)\n'
|
||||
f'2. {os.path.join(module_path, "t4a-fill-25e.pdf")} (module root)\n\n'
|
||||
'The system will automatically fill the PDF with data from this T4A slip when you click "Fill PDF".'
|
||||
)
|
||||
|
||||
# Get field mapping
|
||||
field_mapping = self._get_pdf_field_mapping()
|
||||
|
||||
# Check if we should use text overlay (for flattened PDFs)
|
||||
text_coords = self._get_pdf_text_coordinates()
|
||||
if text_coords:
|
||||
# Use text overlay method for flattened PDF
|
||||
pdf_data = self._overlay_text_on_pdf(template_path, field_mapping)
|
||||
elif use_pdfrw:
|
||||
# Use pdfrw to fill PDF
|
||||
from pdfrw import PdfDict
|
||||
template = PdfReader(template_path)
|
||||
|
||||
# Fill form fields
|
||||
if hasattr(template.Root, 'AcroForm') and template.Root.AcroForm:
|
||||
if hasattr(template.Root.AcroForm, 'Fields') and template.Root.AcroForm.Fields:
|
||||
for field in template.Root.AcroForm.Fields:
|
||||
# Get field name (can be in /T or /TU)
|
||||
field_name = None
|
||||
if hasattr(field, 'T'):
|
||||
field_name = str(field.T).strip('()')
|
||||
elif hasattr(field, 'TU'):
|
||||
field_name = str(field.TU).strip('()')
|
||||
|
||||
if field_name and field_name in field_mapping:
|
||||
value = field_mapping[field_name]
|
||||
if value is not None and value != '':
|
||||
# Set field value
|
||||
field.V = str(value)
|
||||
# Make sure field is not read-only
|
||||
if hasattr(field, 'Ff'):
|
||||
field.Ff = 0 # Remove read-only flag
|
||||
|
||||
# Write filled PDF to temporary file
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_file:
|
||||
tmp_path = tmp_file.name
|
||||
|
||||
writer = PdfWriter()
|
||||
writer.write(template, tmp_path)
|
||||
|
||||
# Read filled PDF
|
||||
with open(tmp_path, 'rb') as f:
|
||||
pdf_data = base64.b64encode(f.read())
|
||||
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
# Use PyPDF2 (fallback)
|
||||
with open(template_path, 'rb') as template_file:
|
||||
reader = PyPDF2.PdfReader(template_file)
|
||||
writer = PyPDF2.PdfWriter()
|
||||
|
||||
# Copy pages
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
# Fill form fields
|
||||
field_mapping = self._get_pdf_field_mapping()
|
||||
if reader.get_form_text_fields():
|
||||
writer.update_page_form_field_values(writer.pages[0], field_mapping)
|
||||
|
||||
# Write to bytes
|
||||
output_buffer = io.BytesIO()
|
||||
writer.write(output_buffer)
|
||||
pdf_data = base64.b64encode(output_buffer.getvalue())
|
||||
|
||||
# Generate filename
|
||||
recipient_safe = self.recipient_name.replace(' ', '_').replace(',', '')[:30]
|
||||
filename = f'T4A_{self.tax_year}_{recipient_safe}.pdf'
|
||||
|
||||
# Save filled PDF
|
||||
self.write({
|
||||
'filled_pdf': pdf_data,
|
||||
'filled_pdf_filename': filename,
|
||||
})
|
||||
|
||||
# Post to chatter
|
||||
self.message_post(
|
||||
body=f'T4A PDF generated: <strong>{filename}</strong>',
|
||||
attachment_ids=[(0, 0, {
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': pdf_data,
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'mimetype': 'application/pdf',
|
||||
})],
|
||||
)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'PDF Generated',
|
||||
'message': f'T4A PDF filled and saved: {filename}',
|
||||
'type': 'success',
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise UserError(f'Error filling PDF: {str(e)}')
|
||||
|
||||
def action_extract_pdf_fields(self):
|
||||
"""Helper method to extract PDF form field names (for debugging)"""
|
||||
self.ensure_one()
|
||||
|
||||
try:
|
||||
from pdfrw import PdfReader
|
||||
except ImportError:
|
||||
raise UserError('pdfrw library not installed. Install with: pip install pdfrw')
|
||||
|
||||
# Get PDF template path - try multiple locations
|
||||
module_path = os.path.dirname(os.path.dirname(__file__))
|
||||
template_path = os.path.join(module_path, 'static', 'pdf', 't4a-fill-25e.pdf')
|
||||
|
||||
if not os.path.exists(template_path):
|
||||
template_path = os.path.join(module_path, 't4a-fill-25e.pdf')
|
||||
|
||||
if not os.path.exists(template_path):
|
||||
try:
|
||||
template_path = tools.file_path('fusion_payroll/static/pdf/t4a-fill-25e.pdf')
|
||||
except:
|
||||
template_path = None
|
||||
|
||||
if not template_path or not os.path.exists(template_path):
|
||||
raise UserError('T4A PDF template not found. Please ensure t4a-fill-25e.pdf is in static/pdf/ or module root.')
|
||||
|
||||
template = PdfReader(template_path)
|
||||
field_names = []
|
||||
|
||||
# Extract field names from all pages
|
||||
for page_num, page in enumerate(template.pages, 1):
|
||||
if hasattr(page, 'Annots') and page.Annots:
|
||||
for annot in page.Annots:
|
||||
if hasattr(annot, 'Subtype') and str(annot.Subtype) == '/Widget':
|
||||
if hasattr(annot, 'T'):
|
||||
field_name = str(annot.T).strip('()')
|
||||
field_names.append(f'Page {page_num}: {field_name}')
|
||||
|
||||
# Return as message
|
||||
if field_names:
|
||||
message = 'PDF Form Fields Found:\n\n' + '\n'.join(field_names[:50])
|
||||
if len(field_names) > 50:
|
||||
message += f'\n\n... and {len(field_names) - 50} more fields'
|
||||
else:
|
||||
message = 'No form fields found in PDF. The PDF may not be a fillable form, or field names are stored differently.'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'PDF Fields',
|
||||
'message': message,
|
||||
'type': 'info',
|
||||
'sticky': True,
|
||||
}
|
||||
}
|
||||
|
||||
def _get_pdf_field_mapping(self):
|
||||
"""Map model fields to PDF form field names"""
|
||||
# This mapping may need to be adjusted based on actual PDF form field names
|
||||
# Common field name patterns for T4A forms:
|
||||
# You can use action_extract_pdf_fields() to see actual field names in the PDF
|
||||
mapping = {}
|
||||
|
||||
# Year
|
||||
mapping['Year'] = str(self.tax_year)
|
||||
mapping['year'] = str(self.tax_year)
|
||||
mapping['YEAR'] = str(self.tax_year)
|
||||
|
||||
# Payer information (from company)
|
||||
company = self.company_id
|
||||
if company:
|
||||
mapping['PayerName'] = company.name or ''
|
||||
mapping['PayerName1'] = company.name or ''
|
||||
if company.street:
|
||||
mapping['PayerAddress1'] = company.street
|
||||
if company.street2:
|
||||
mapping['PayerAddress2'] = company.street2
|
||||
if company.city:
|
||||
city_line = company.city
|
||||
if company.state_id:
|
||||
city_line += f", {company.state_id.code}"
|
||||
if company.zip:
|
||||
city_line += f" {company.zip}"
|
||||
mapping['PayerCity'] = city_line
|
||||
|
||||
# Payer account number
|
||||
settings = self.env['payroll.config.settings'].get_settings(company.id)
|
||||
account_num = settings.get_cra_payroll_account_number() or company.vat or ''
|
||||
mapping['PayerAccount'] = account_num
|
||||
mapping['Box54'] = account_num
|
||||
|
||||
# Recipient information
|
||||
if self.recipient_name:
|
||||
# Split name into last, first
|
||||
name_parts = self.recipient_name.split(',')
|
||||
if len(name_parts) >= 2:
|
||||
mapping['LastName'] = name_parts[0].strip()
|
||||
mapping['FirstName'] = name_parts[1].strip()
|
||||
else:
|
||||
# Try to split by space
|
||||
name_parts = self.recipient_name.split()
|
||||
if len(name_parts) >= 2:
|
||||
mapping['LastName'] = name_parts[-1]
|
||||
mapping['FirstName'] = ' '.join(name_parts[:-1])
|
||||
else:
|
||||
mapping['LastName'] = self.recipient_name
|
||||
|
||||
if self.recipient_address:
|
||||
addr_lines = self.recipient_address.split('\n')
|
||||
for i, line in enumerate(addr_lines[:3], 1):
|
||||
mapping[f'RecipientAddress{i}'] = line
|
||||
|
||||
if self.recipient_sin:
|
||||
mapping['SIN'] = self.recipient_sin.replace('-', '').replace(' ', '')
|
||||
mapping['Box12'] = self.recipient_sin.replace('-', '').replace(' ', '')
|
||||
|
||||
if self.recipient_account_number:
|
||||
mapping['Box13'] = self.recipient_account_number
|
||||
|
||||
# Income boxes
|
||||
if self.box_016_pension:
|
||||
mapping['Box016'] = f"{self.box_016_pension:.2f}"
|
||||
mapping['016'] = f"{self.box_016_pension:.2f}"
|
||||
|
||||
if self.box_018_lump_sum:
|
||||
mapping['Box018'] = f"{self.box_018_lump_sum:.2f}"
|
||||
mapping['018'] = f"{self.box_018_lump_sum:.2f}"
|
||||
|
||||
if self.box_020_commissions:
|
||||
mapping['Box020'] = f"{self.box_020_commissions:.2f}"
|
||||
mapping['020'] = f"{self.box_020_commissions:.2f}"
|
||||
|
||||
if self.box_024_annuities:
|
||||
mapping['Box024'] = f"{self.box_024_annuities:.2f}"
|
||||
mapping['024'] = f"{self.box_024_annuities:.2f}"
|
||||
|
||||
if self.box_048_fees:
|
||||
mapping['Box048'] = f"{self.box_048_fees:.2f}"
|
||||
mapping['048'] = f"{self.box_048_fees:.2f}"
|
||||
|
||||
# Other information boxes
|
||||
for other_info in self.other_info_ids:
|
||||
box_num = str(other_info.box_number).zfill(3)
|
||||
mapping[f'Box{box_num}'] = f"{other_info.amount:.2f}"
|
||||
mapping[box_num] = f"{other_info.amount:.2f}"
|
||||
|
||||
return mapping
|
||||
|
||||
def action_download_pdf(self):
|
||||
"""Download the filled PDF"""
|
||||
self.ensure_one()
|
||||
if not self.filled_pdf:
|
||||
raise UserError('No PDF has been generated yet. Please click "Fill PDF" first.')
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'/web/content/hr.t4a.slip/{self.id}/filled_pdf/{self.filled_pdf_filename}?download=true',
|
||||
'target': 'self',
|
||||
}
|
||||
|
||||
|
||||
class HrT4AOtherInfo(models.Model):
|
||||
"""T4A Other Information (Boxes 028-197)"""
|
||||
_name = 'hr.t4a.other.info'
|
||||
_description = 'T4A Other Information'
|
||||
_order = 'box_number'
|
||||
|
||||
slip_id = fields.Many2one(
|
||||
'hr.t4a.slip',
|
||||
string='T4A Slip',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
box_number = fields.Integer(
|
||||
string='Box Number',
|
||||
required=True,
|
||||
help='Box number (028-197)',
|
||||
)
|
||||
currency_id_slip = fields.Many2one(
|
||||
related='slip_id.currency_id',
|
||||
string='Currency',
|
||||
)
|
||||
amount = fields.Monetary(
|
||||
string='Amount',
|
||||
currency_field='currency_id_slip',
|
||||
required=True,
|
||||
)
|
||||
description = fields.Char(
|
||||
string='Description',
|
||||
help='Description of this income type',
|
||||
)
|
||||
380
fusion_payroll/models/hr_payslip.py
Normal file
380
fusion_payroll/models/hr_payslip.py
Normal file
@@ -0,0 +1,380 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class HrPayslip(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
# === Additional Fields for QuickBooks-style Paycheque Entry ===
|
||||
cheque_id = fields.Many2one(
|
||||
'payroll.cheque',
|
||||
string='Cheque',
|
||||
copy=False,
|
||||
help='Linked cheque record for paper cheque payments',
|
||||
)
|
||||
cheque_number = fields.Char(
|
||||
string='Cheque Number',
|
||||
related='cheque_id.cheque_number',
|
||||
store=True,
|
||||
copy=False,
|
||||
help='Cheque number for paper cheque payments',
|
||||
)
|
||||
cheque_state = fields.Selection(
|
||||
related='cheque_id.state',
|
||||
string='Cheque Status',
|
||||
store=True,
|
||||
)
|
||||
memo = fields.Text(
|
||||
string='Memo',
|
||||
help='Internal notes for this paycheque',
|
||||
)
|
||||
paid_by = fields.Selection([
|
||||
('cheque', 'Paper Cheque'),
|
||||
('direct_deposit', 'Direct Deposit'),
|
||||
], string='Payment Method', compute='_compute_paid_by', store=True, readonly=False)
|
||||
|
||||
@api.depends('employee_id', 'employee_id.payment_method')
|
||||
def _compute_paid_by(self):
|
||||
"""Set payment method from employee's default payment method."""
|
||||
for payslip in self:
|
||||
if payslip.employee_id and hasattr(payslip.employee_id, 'payment_method'):
|
||||
payslip.paid_by = payslip.employee_id.payment_method or 'direct_deposit'
|
||||
else:
|
||||
payslip.paid_by = 'direct_deposit'
|
||||
|
||||
def action_print_cheque(self):
|
||||
"""Print cheque for this payslip - always opens wizard to set/change cheque number."""
|
||||
self.ensure_one()
|
||||
|
||||
if self.paid_by != 'cheque':
|
||||
raise UserError(_("This payslip is not set to be paid by cheque."))
|
||||
|
||||
# Create cheque if not exists
|
||||
if not self.cheque_id:
|
||||
cheque = self.env['payroll.cheque'].create_from_payslip(self)
|
||||
if cheque:
|
||||
self.cheque_id = cheque.id
|
||||
else:
|
||||
raise UserError(_("Failed to create cheque. Check employee payment method."))
|
||||
|
||||
# Always open the cheque number wizard to allow changing the number
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Set Cheque Number'),
|
||||
'res_model': 'payroll.cheque.number.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_cheque_id': self.cheque_id.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_create_cheque(self):
|
||||
"""Create a cheque for this payslip without printing."""
|
||||
self.ensure_one()
|
||||
|
||||
if self.paid_by != 'cheque':
|
||||
raise UserError(_("This payslip is not set to be paid by cheque."))
|
||||
|
||||
if self.cheque_id:
|
||||
raise UserError(_("A cheque already exists for this payslip."))
|
||||
|
||||
cheque = self.env['payroll.cheque'].create_from_payslip(self)
|
||||
if cheque:
|
||||
self.cheque_id = cheque.id
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Cheque Created'),
|
||||
'message': _('Cheque created for %s.') % self.employee_id.name,
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
else:
|
||||
raise UserError(_("Failed to create cheque."))
|
||||
|
||||
# === YTD Computed Fields ===
|
||||
ytd_gross = fields.Monetary(
|
||||
string='YTD Gross',
|
||||
compute='_compute_ytd_amounts',
|
||||
currency_field='currency_id',
|
||||
help='Year-to-date gross earnings',
|
||||
)
|
||||
ytd_cpp = fields.Monetary(
|
||||
string='YTD CPP',
|
||||
compute='_compute_ytd_amounts',
|
||||
currency_field='currency_id',
|
||||
help='Year-to-date CPP contributions (employee)',
|
||||
)
|
||||
ytd_cpp2 = fields.Monetary(
|
||||
string='YTD CPP2',
|
||||
compute='_compute_ytd_amounts',
|
||||
currency_field='currency_id',
|
||||
help='Year-to-date CPP2 contributions (employee)',
|
||||
)
|
||||
ytd_ei = fields.Monetary(
|
||||
string='YTD EI',
|
||||
compute='_compute_ytd_amounts',
|
||||
currency_field='currency_id',
|
||||
help='Year-to-date EI contributions (employee)',
|
||||
)
|
||||
ytd_income_tax = fields.Monetary(
|
||||
string='YTD Income Tax',
|
||||
compute='_compute_ytd_amounts',
|
||||
currency_field='currency_id',
|
||||
help='Year-to-date income tax withheld',
|
||||
)
|
||||
ytd_net = fields.Monetary(
|
||||
string='YTD Net',
|
||||
compute='_compute_ytd_amounts',
|
||||
currency_field='currency_id',
|
||||
help='Year-to-date net pay',
|
||||
)
|
||||
|
||||
# === Employer Tax Totals (for display) ===
|
||||
employer_cpp = fields.Monetary(
|
||||
string='Employer CPP',
|
||||
compute='_compute_employer_contributions',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
employer_cpp2 = fields.Monetary(
|
||||
string='Employer CPP2',
|
||||
compute='_compute_employer_contributions',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
employer_ei = fields.Monetary(
|
||||
string='Employer EI',
|
||||
compute='_compute_employer_contributions',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
total_employer_cost = fields.Monetary(
|
||||
string='Total Employer Cost',
|
||||
compute='_compute_employer_contributions',
|
||||
currency_field='currency_id',
|
||||
help='Total employer contributions (CPP + CPP2 + EI)',
|
||||
)
|
||||
|
||||
# === Employee Tax Totals (for summary) ===
|
||||
employee_cpp = fields.Monetary(
|
||||
string='Employee CPP',
|
||||
compute='_compute_employee_deductions',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
employee_cpp2 = fields.Monetary(
|
||||
string='Employee CPP2',
|
||||
compute='_compute_employee_deductions',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
employee_ei = fields.Monetary(
|
||||
string='Employee EI',
|
||||
compute='_compute_employee_deductions',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
employee_income_tax = fields.Monetary(
|
||||
string='Income Tax',
|
||||
compute='_compute_employee_deductions',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
total_employee_deductions = fields.Monetary(
|
||||
string='Total Employee Deductions',
|
||||
compute='_compute_employee_deductions',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'date_from', 'line_ids', 'line_ids.total')
|
||||
def _compute_ytd_amounts(self):
|
||||
"""Calculate year-to-date amounts for each payslip"""
|
||||
for payslip in self:
|
||||
if not payslip.employee_id or not payslip.date_from:
|
||||
payslip.ytd_gross = 0
|
||||
payslip.ytd_cpp = 0
|
||||
payslip.ytd_cpp2 = 0
|
||||
payslip.ytd_ei = 0
|
||||
payslip.ytd_income_tax = 0
|
||||
payslip.ytd_net = 0
|
||||
continue
|
||||
|
||||
# Get the start of the year
|
||||
year_start = payslip.date_from.replace(month=1, day=1)
|
||||
|
||||
# Find all payslips for this employee in the same year, up to and including this one
|
||||
domain = [
|
||||
('employee_id', '=', payslip.employee_id.id),
|
||||
('date_from', '>=', year_start),
|
||||
('date_to', '<=', payslip.date_to),
|
||||
('state', 'in', ['done', 'paid']),
|
||||
]
|
||||
# Include current payslip if it's in draft/verify state
|
||||
if payslip.state in ['draft', 'verify']:
|
||||
domain = ['|', ('id', '=', payslip.id)] + domain
|
||||
|
||||
ytd_payslips = self.search(domain)
|
||||
|
||||
# Calculate YTD totals
|
||||
ytd_gross = 0
|
||||
ytd_cpp = 0
|
||||
ytd_cpp2 = 0
|
||||
ytd_ei = 0
|
||||
ytd_income_tax = 0
|
||||
ytd_net = 0
|
||||
|
||||
for slip in ytd_payslips:
|
||||
ytd_gross += slip.gross_wage or 0
|
||||
ytd_net += slip.net_wage or 0
|
||||
|
||||
# Sum up specific rule amounts
|
||||
for line in slip.line_ids:
|
||||
code = line.code or ''
|
||||
if code == 'CPP':
|
||||
ytd_cpp += abs(line.total or 0)
|
||||
elif code == 'CPP2':
|
||||
ytd_cpp2 += abs(line.total or 0)
|
||||
elif code == 'EI':
|
||||
ytd_ei += abs(line.total or 0)
|
||||
elif code in ['FED_TAX', 'PROV_TAX', 'INCOME_TAX']:
|
||||
ytd_income_tax += abs(line.total or 0)
|
||||
|
||||
payslip.ytd_gross = ytd_gross
|
||||
payslip.ytd_cpp = ytd_cpp
|
||||
payslip.ytd_cpp2 = ytd_cpp2
|
||||
payslip.ytd_ei = ytd_ei
|
||||
payslip.ytd_income_tax = ytd_income_tax
|
||||
payslip.ytd_net = ytd_net
|
||||
|
||||
@api.depends('line_ids', 'line_ids.total', 'line_ids.code')
|
||||
def _compute_employer_contributions(self):
|
||||
"""Calculate employer contribution totals from payslip lines"""
|
||||
for payslip in self:
|
||||
employer_cpp = 0
|
||||
employer_cpp2 = 0
|
||||
employer_ei = 0
|
||||
|
||||
for line in payslip.line_ids:
|
||||
code = line.code or ''
|
||||
if code == 'CPP_ER':
|
||||
employer_cpp = abs(line.total or 0)
|
||||
elif code == 'CPP2_ER':
|
||||
employer_cpp2 = abs(line.total or 0)
|
||||
elif code == 'EI_ER':
|
||||
employer_ei = abs(line.total or 0)
|
||||
|
||||
payslip.employer_cpp = employer_cpp
|
||||
payslip.employer_cpp2 = employer_cpp2
|
||||
payslip.employer_ei = employer_ei
|
||||
payslip.total_employer_cost = employer_cpp + employer_cpp2 + employer_ei
|
||||
|
||||
@api.depends('line_ids', 'line_ids.total', 'line_ids.code')
|
||||
def _compute_employee_deductions(self):
|
||||
"""Calculate employee deduction totals from payslip lines"""
|
||||
for payslip in self:
|
||||
employee_cpp = 0
|
||||
employee_cpp2 = 0
|
||||
employee_ei = 0
|
||||
employee_income_tax = 0
|
||||
|
||||
for line in payslip.line_ids:
|
||||
code = line.code or ''
|
||||
if code == 'CPP':
|
||||
employee_cpp = abs(line.total or 0)
|
||||
elif code == 'CPP2':
|
||||
employee_cpp2 = abs(line.total or 0)
|
||||
elif code == 'EI':
|
||||
employee_ei = abs(line.total or 0)
|
||||
elif code in ['FED_TAX', 'PROV_TAX', 'INCOME_TAX']:
|
||||
employee_income_tax += abs(line.total or 0)
|
||||
|
||||
payslip.employee_cpp = employee_cpp
|
||||
payslip.employee_cpp2 = employee_cpp2
|
||||
payslip.employee_ei = employee_ei
|
||||
payslip.employee_income_tax = employee_income_tax
|
||||
payslip.total_employee_deductions = (
|
||||
employee_cpp + employee_cpp2 + employee_ei + employee_income_tax
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# PAY TYPE IDENTIFICATION HELPERS (for T4 and ROE reporting)
|
||||
# =========================================================================
|
||||
|
||||
@api.model
|
||||
def _get_pay_type_from_code(self, code, category_code=None):
|
||||
"""
|
||||
Map salary rule code to pay type for ROE/T4 reporting.
|
||||
|
||||
Returns one of: 'salary', 'hourly', 'overtime', 'bonus', 'stat_holiday',
|
||||
'commission', 'allowance', 'reimbursement', 'union_dues', 'other'
|
||||
|
||||
:param code: Salary rule code (e.g., 'OT_PAY', 'BONUS_PAY')
|
||||
:param category_code: Category code (e.g., 'BASIC', 'ALW', 'DED')
|
||||
:return: Pay type string
|
||||
"""
|
||||
if not code:
|
||||
return 'other'
|
||||
|
||||
code_upper = code.upper()
|
||||
|
||||
# Direct code matches
|
||||
if code_upper == 'OT_PAY':
|
||||
return 'overtime'
|
||||
elif code_upper == 'BONUS_PAY' or code_upper == 'BONUS':
|
||||
return 'bonus'
|
||||
elif code_upper == 'STAT_PAY' or code_upper == 'STAT_HOLIDAY':
|
||||
return 'stat_holiday'
|
||||
|
||||
# Pattern matching for commissions
|
||||
if 'COMMISSION' in code_upper or 'COMM' in code_upper:
|
||||
return 'commission'
|
||||
|
||||
# Pattern matching for union dues
|
||||
if 'UNION' in code_upper or 'DUES' in code_upper:
|
||||
return 'union_dues'
|
||||
|
||||
# Pattern matching for reimbursements
|
||||
if 'REIMBURSEMENT' in code_upper or 'REIMB' in code_upper:
|
||||
return 'reimbursement'
|
||||
|
||||
# Pattern matching for allowances
|
||||
if 'ALLOWANCE' in code_upper or 'ALW' in code_upper:
|
||||
# Check if it's a reimbursement first
|
||||
if 'REIMBURSEMENT' in code_upper or 'REIMB' in code_upper:
|
||||
return 'reimbursement'
|
||||
return 'allowance'
|
||||
|
||||
# Category-based identification
|
||||
if category_code:
|
||||
category_upper = category_code.upper()
|
||||
if category_upper == 'BASIC':
|
||||
return 'salary' # Could be salary or hourly, default to salary
|
||||
elif category_upper == 'ALW':
|
||||
# Already checked for allowance patterns above
|
||||
return 'allowance'
|
||||
elif category_upper == 'DED':
|
||||
# Deductions - check if union dues
|
||||
if 'UNION' in code_upper or 'DUES' in code_upper:
|
||||
return 'union_dues'
|
||||
return 'other'
|
||||
|
||||
return 'other'
|
||||
|
||||
@api.model
|
||||
def _is_reimbursement(self, code, category_code=None):
|
||||
"""
|
||||
Check if salary rule code represents a reimbursement (non-taxable).
|
||||
|
||||
:param code: Salary rule code
|
||||
:param category_code: Category code
|
||||
:return: True if reimbursement, False otherwise
|
||||
"""
|
||||
if not code:
|
||||
return False
|
||||
|
||||
code_upper = code.upper()
|
||||
|
||||
# Direct pattern matching
|
||||
if 'REIMBURSEMENT' in code_upper or 'REIMB' in code_upper:
|
||||
return True
|
||||
|
||||
return False
|
||||
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',
|
||||
)
|
||||
89
fusion_payroll/models/hr_salary_rule_category.py
Normal file
89
fusion_payroll/models/hr_salary_rule_category.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class HrSalaryRuleCategory(models.Model):
|
||||
_inherit = 'hr.salary.rule.category'
|
||||
|
||||
# === Canadian Pension Plan (CPP) Configuration ===
|
||||
cpp_deduction_id = fields.Many2one(
|
||||
'tax.yearly.rates',
|
||||
string='Canadian Configuration Values',
|
||||
domain="[('ded_type', '=', 'cpp')]",
|
||||
help='Link to CPP yearly rates configuration',
|
||||
)
|
||||
cpp_date = fields.Date(
|
||||
string='CPP Date',
|
||||
related='cpp_deduction_id.cpp_date',
|
||||
readonly=True,
|
||||
)
|
||||
max_cpp = fields.Float(
|
||||
string='Maximum CPP',
|
||||
related='cpp_deduction_id.max_cpp',
|
||||
readonly=True,
|
||||
)
|
||||
emp_contribution_rate = fields.Float(
|
||||
string='Employee Contribution Rate',
|
||||
related='cpp_deduction_id.emp_contribution_rate',
|
||||
readonly=True,
|
||||
)
|
||||
employer_contribution_rate = fields.Float(
|
||||
string='Employer Contribution Rate',
|
||||
related='cpp_deduction_id.employer_contribution_rate',
|
||||
readonly=True,
|
||||
)
|
||||
exemption = fields.Float(
|
||||
string='Exemption Amount',
|
||||
related='cpp_deduction_id.exemption',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# === Employment Insurance (EI) Configuration ===
|
||||
ei_deduction_id = fields.Many2one(
|
||||
'tax.yearly.rates',
|
||||
string='Employment Insurance',
|
||||
domain="[('ded_type', '=', 'ei')]",
|
||||
help='Link to EI yearly rates configuration',
|
||||
)
|
||||
ei_date = fields.Date(
|
||||
string='EI Date',
|
||||
related='ei_deduction_id.ei_date',
|
||||
readonly=True,
|
||||
)
|
||||
ei_rate = fields.Float(
|
||||
string='EI Rate',
|
||||
related='ei_deduction_id.ei_rate',
|
||||
readonly=True,
|
||||
)
|
||||
ei_earnings = fields.Float(
|
||||
string='Maximum EI Earnings',
|
||||
related='ei_deduction_id.ei_earnings',
|
||||
readonly=True,
|
||||
)
|
||||
emp_ei_amount = fields.Float(
|
||||
string='Employee EI Amount',
|
||||
related='ei_deduction_id.emp_ei_amount',
|
||||
readonly=True,
|
||||
)
|
||||
employer_ei_amount = fields.Float(
|
||||
string='Employer EI Amount',
|
||||
related='ei_deduction_id.employer_ei_amount',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# === Federal Tax Configuration ===
|
||||
fed_tax_id = fields.Many2one(
|
||||
'tax.yearly.rates',
|
||||
string='Federal Tax',
|
||||
domain="[('tax_type', '=', 'federal')]",
|
||||
help='Link to Federal tax yearly rates configuration',
|
||||
)
|
||||
|
||||
# === Provincial Tax Configuration ===
|
||||
provincial_tax_id = fields.Many2one(
|
||||
'tax.yearly.rates',
|
||||
string='Provincial Tax',
|
||||
domain="[('tax_type', '=', 'provincial')]",
|
||||
help='Link to Provincial tax yearly rates configuration',
|
||||
)
|
||||
306
fusion_payroll/models/hr_tax_remittance.py
Normal file
306
fusion_payroll/models/hr_tax_remittance.py
Normal file
@@ -0,0 +1,306 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from datetime import date, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class HrTaxRemittance(models.Model):
|
||||
_name = 'hr.tax.remittance'
|
||||
_description = 'Payroll Tax Remittance'
|
||||
_order = 'period_start desc'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
STATE_SELECTION = [
|
||||
('draft', 'Draft'),
|
||||
('pending', 'Pending'),
|
||||
('due', 'Due Soon'),
|
||||
('past_due', 'Past Due'),
|
||||
('paid', 'Paid'),
|
||||
]
|
||||
|
||||
PERIOD_TYPE = [
|
||||
('monthly', 'Monthly'),
|
||||
('quarterly', 'Quarterly'),
|
||||
]
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
default=lambda self: self.env['ir.sequence'].next_by_code('hr.tax.remittance') or 'New',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
related='company_id.currency_id',
|
||||
string='Currency',
|
||||
)
|
||||
state = fields.Selection(
|
||||
selection=STATE_SELECTION,
|
||||
string='Status',
|
||||
default='draft',
|
||||
tracking=True,
|
||||
compute='_compute_state',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# === Period Information ===
|
||||
period_type = fields.Selection(
|
||||
selection=PERIOD_TYPE,
|
||||
string='Period Type',
|
||||
default='monthly',
|
||||
)
|
||||
period_start = fields.Date(
|
||||
string='Period Start',
|
||||
required=True,
|
||||
)
|
||||
period_end = fields.Date(
|
||||
string='Period End',
|
||||
required=True,
|
||||
)
|
||||
due_date = fields.Date(
|
||||
string='Due Date',
|
||||
required=True,
|
||||
help='CRA remittance due date (15th of following month for regular remitters)',
|
||||
)
|
||||
|
||||
# === CPP Amounts ===
|
||||
cpp_employee = fields.Monetary(
|
||||
string='CPP Employee',
|
||||
currency_field='currency_id',
|
||||
help='Total employee CPP contributions for period',
|
||||
)
|
||||
cpp_employer = fields.Monetary(
|
||||
string='CPP Employer',
|
||||
currency_field='currency_id',
|
||||
help='Total employer CPP contributions for period',
|
||||
)
|
||||
cpp2_employee = fields.Monetary(
|
||||
string='CPP2 Employee',
|
||||
currency_field='currency_id',
|
||||
help='Total employee CPP2 (second CPP) contributions for period',
|
||||
)
|
||||
cpp2_employer = fields.Monetary(
|
||||
string='CPP2 Employer',
|
||||
currency_field='currency_id',
|
||||
help='Total employer CPP2 contributions for period',
|
||||
)
|
||||
|
||||
# === EI Amounts ===
|
||||
ei_employee = fields.Monetary(
|
||||
string='EI Employee',
|
||||
currency_field='currency_id',
|
||||
help='Total employee EI premiums for period',
|
||||
)
|
||||
ei_employer = fields.Monetary(
|
||||
string='EI Employer',
|
||||
currency_field='currency_id',
|
||||
help='Total employer EI premiums (1.4x employee) for period',
|
||||
)
|
||||
|
||||
# === Tax Amounts ===
|
||||
income_tax = fields.Monetary(
|
||||
string='Income Tax',
|
||||
currency_field='currency_id',
|
||||
help='Total federal + provincial income tax withheld for period',
|
||||
)
|
||||
|
||||
# === Totals ===
|
||||
total = fields.Monetary(
|
||||
string='Total Remittance',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_total',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# === Payment Information ===
|
||||
payment_date = fields.Date(
|
||||
string='Payment Date',
|
||||
tracking=True,
|
||||
)
|
||||
payment_reference = fields.Char(
|
||||
string='Payment Reference',
|
||||
help='Bank reference or confirmation number',
|
||||
)
|
||||
payment_method = fields.Selection([
|
||||
('cra_my_business', 'CRA My Business Account'),
|
||||
('bank_payment', 'Bank Payment'),
|
||||
('cheque', 'Cheque'),
|
||||
], string='Payment Method')
|
||||
|
||||
# === Linked Payslips ===
|
||||
payslip_ids = fields.Many2many(
|
||||
'hr.payslip',
|
||||
string='Related Payslips',
|
||||
help='Payslips included in this remittance period',
|
||||
)
|
||||
payslip_count = fields.Integer(
|
||||
string='Payslip Count',
|
||||
compute='_compute_payslip_count',
|
||||
)
|
||||
|
||||
@api.depends('cpp_employee', 'cpp_employer', 'cpp2_employee', 'cpp2_employer',
|
||||
'ei_employee', 'ei_employer', 'income_tax')
|
||||
def _compute_total(self):
|
||||
for rec in self:
|
||||
rec.total = (
|
||||
rec.cpp_employee + rec.cpp_employer +
|
||||
rec.cpp2_employee + rec.cpp2_employer +
|
||||
rec.ei_employee + rec.ei_employer +
|
||||
rec.income_tax
|
||||
)
|
||||
|
||||
@api.depends('due_date', 'payment_date')
|
||||
def _compute_state(self):
|
||||
today = date.today()
|
||||
for rec in self:
|
||||
if rec.payment_date:
|
||||
rec.state = 'paid'
|
||||
elif not rec.due_date:
|
||||
rec.state = 'draft'
|
||||
elif rec.due_date < today:
|
||||
rec.state = 'past_due'
|
||||
elif rec.due_date <= today + timedelta(days=7):
|
||||
rec.state = 'due'
|
||||
else:
|
||||
rec.state = 'pending'
|
||||
|
||||
def _compute_payslip_count(self):
|
||||
for rec in self:
|
||||
rec.payslip_count = len(rec.payslip_ids)
|
||||
|
||||
def action_calculate_amounts(self):
|
||||
"""Calculate remittance amounts from payslips in the period"""
|
||||
self.ensure_one()
|
||||
|
||||
# Find all confirmed payslips in the period
|
||||
payslips = self.env['hr.payslip'].search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
('state', 'in', ['validated', 'paid']),
|
||||
('date_from', '>=', self.period_start),
|
||||
('date_to', '<=', self.period_end),
|
||||
])
|
||||
|
||||
if not payslips:
|
||||
raise UserError('No confirmed payslips found for this period.')
|
||||
|
||||
# Sum up amounts by rule code
|
||||
cpp_ee = cpp_er = cpp2_ee = cpp2_er = 0
|
||||
ei_ee = ei_er = 0
|
||||
income_tax = 0
|
||||
|
||||
for payslip in payslips:
|
||||
for line in payslip.line_ids:
|
||||
code = line.code
|
||||
amount = abs(line.total)
|
||||
|
||||
if code == 'CPP_EE':
|
||||
cpp_ee += amount
|
||||
elif code == 'CPP_ER':
|
||||
cpp_er += amount
|
||||
elif code == 'CPP2_EE':
|
||||
cpp2_ee += amount
|
||||
elif code == 'CPP2_ER':
|
||||
cpp2_er += amount
|
||||
elif code == 'EI_EE':
|
||||
ei_ee += amount
|
||||
elif code == 'EI_ER':
|
||||
ei_er += amount
|
||||
elif code in ('FED_TAX', 'PROV_TAX'):
|
||||
income_tax += amount
|
||||
|
||||
self.write({
|
||||
'cpp_employee': cpp_ee,
|
||||
'cpp_employer': cpp_er,
|
||||
'cpp2_employee': cpp2_ee,
|
||||
'cpp2_employer': cpp2_er,
|
||||
'ei_employee': ei_ee,
|
||||
'ei_employer': ei_er,
|
||||
'income_tax': income_tax,
|
||||
'payslip_ids': [(6, 0, payslips.ids)],
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Amounts Calculated',
|
||||
'message': f'Calculated from {len(payslips)} payslips. Total: ${self.total:,.2f}',
|
||||
'type': 'success',
|
||||
}
|
||||
}
|
||||
|
||||
def action_mark_paid(self):
|
||||
"""Mark remittance as paid"""
|
||||
self.ensure_one()
|
||||
if not self.payment_date:
|
||||
self.payment_date = date.today()
|
||||
self.state = 'paid'
|
||||
|
||||
def action_view_payslips(self):
|
||||
"""View related payslips"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Payslips',
|
||||
'res_model': 'hr.payslip',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', self.payslip_ids.ids)],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_payment_frequency_from_settings(self, company_id=None):
|
||||
"""Get payment frequency from payroll settings."""
|
||||
if not company_id:
|
||||
company_id = self.env.company.id
|
||||
settings = self.env['payroll.config.settings'].get_settings(company_id)
|
||||
return settings.federal_tax_payment_frequency or 'monthly'
|
||||
|
||||
def create_monthly_remittance(self, year, month, company_id=None):
|
||||
"""Create a monthly remittance record"""
|
||||
if not company_id:
|
||||
company_id = self.env.company.id
|
||||
|
||||
# Get settings for payment frequency and CRA info
|
||||
settings = self.env['payroll.config.settings'].get_settings(company_id)
|
||||
payment_freq = settings.federal_tax_payment_frequency or 'monthly'
|
||||
|
||||
# Calculate period dates
|
||||
period_start = date(year, month, 1)
|
||||
if month == 12:
|
||||
period_end = date(year + 1, 1, 1) - timedelta(days=1)
|
||||
due_date = date(year + 1, 1, 15)
|
||||
else:
|
||||
period_end = date(year, month + 1, 1) - timedelta(days=1)
|
||||
due_date = date(year, month + 1, 15)
|
||||
|
||||
# Create the remittance
|
||||
remittance = self.create({
|
||||
'company_id': company_id,
|
||||
'period_type': payment_freq if payment_freq in ['monthly', 'quarterly'] else 'monthly',
|
||||
'period_start': period_start,
|
||||
'period_end': period_end,
|
||||
'due_date': due_date,
|
||||
})
|
||||
|
||||
# Calculate amounts
|
||||
remittance.action_calculate_amounts()
|
||||
|
||||
return remittance
|
||||
|
||||
|
||||
class HrTaxRemittanceSequence(models.Model):
|
||||
"""Create sequence for tax remittance"""
|
||||
_name = 'hr.tax.remittance.sequence'
|
||||
_description = 'Tax Remittance Sequence Setup'
|
||||
_auto = False
|
||||
|
||||
def init(self):
|
||||
# This will be handled by ir.sequence data instead
|
||||
pass
|
||||
387
fusion_payroll/models/pay_period.py
Normal file
387
fusion_payroll/models/pay_period.py
Normal file
@@ -0,0 +1,387 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from datetime import date, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
class PayPeriod(models.Model):
|
||||
"""
|
||||
Pay Period Management
|
||||
Stores configured pay periods with auto-generation of future periods.
|
||||
"""
|
||||
_name = 'payroll.pay.period'
|
||||
_description = 'Pay Period'
|
||||
_order = 'date_start asc' # Chronological: oldest first (scroll UP for past, scroll DOWN for future)
|
||||
_rec_name = 'name'
|
||||
|
||||
display_order = fields.Integer(
|
||||
string='Display Order',
|
||||
compute='_compute_display_order',
|
||||
store=True,
|
||||
help='For reference only - actual ordering uses date_start desc',
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
ondelete='cascade',
|
||||
)
|
||||
|
||||
date_start = fields.Date(
|
||||
string='Period Start',
|
||||
required=True,
|
||||
)
|
||||
date_end = fields.Date(
|
||||
string='Period End',
|
||||
required=True,
|
||||
)
|
||||
pay_date = fields.Date(
|
||||
string='Pay Date',
|
||||
help='Date when employees receive payment',
|
||||
)
|
||||
|
||||
schedule_type = fields.Selection([
|
||||
('weekly', 'Weekly'),
|
||||
('biweekly', 'Bi-Weekly'),
|
||||
('semi_monthly', 'Semi-Monthly'),
|
||||
('monthly', 'Monthly'),
|
||||
], string='Schedule Type', required=True, default='biweekly')
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'Open'),
|
||||
('in_progress', 'In Progress'),
|
||||
('paid', 'Paid'),
|
||||
('closed', 'Closed'),
|
||||
], string='Status', default='draft', tracking=True)
|
||||
|
||||
payslip_run_id = fields.Many2one(
|
||||
'hr.payslip.run',
|
||||
string='Payslip Batch',
|
||||
ondelete='set null',
|
||||
)
|
||||
|
||||
name = fields.Char(
|
||||
string='Period Name',
|
||||
compute='_compute_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
is_current = fields.Boolean(
|
||||
string='Current Period',
|
||||
compute='_compute_is_current',
|
||||
)
|
||||
|
||||
@api.depends('date_start', 'date_end')
|
||||
def _compute_name(self):
|
||||
for period in self:
|
||||
if period.date_start and period.date_end:
|
||||
period.name = f"{period.date_start.strftime('%m.%d.%Y')} to {period.date_end.strftime('%m.%d.%Y')}"
|
||||
else:
|
||||
period.name = _('New Period')
|
||||
|
||||
def _compute_is_current(self):
|
||||
today = fields.Date.context_today(self)
|
||||
for period in self:
|
||||
period.is_current = period.date_start <= today <= period.date_end
|
||||
|
||||
@api.depends('date_start', 'date_end')
|
||||
def _compute_display_order(self):
|
||||
"""
|
||||
Compute display order for proper dropdown sorting:
|
||||
- Current period: 0
|
||||
- Future periods: 1-999 (by days from today)
|
||||
- Past periods: 1000+ (by days from today, reversed)
|
||||
"""
|
||||
today = fields.Date.context_today(self)
|
||||
for period in self:
|
||||
if not period.date_start or not period.date_end:
|
||||
period.display_order = 9999
|
||||
elif period.date_start <= today <= period.date_end:
|
||||
# Current period - top priority
|
||||
period.display_order = 0
|
||||
elif period.date_start > today:
|
||||
# Future period - closer dates have lower order
|
||||
days_ahead = (period.date_start - today).days
|
||||
period.display_order = min(days_ahead, 999)
|
||||
else:
|
||||
# Past period - more recent dates have lower order
|
||||
days_ago = (today - period.date_end).days
|
||||
period.display_order = 1000 + days_ago
|
||||
|
||||
@api.constrains('date_start', 'date_end')
|
||||
def _check_dates(self):
|
||||
for period in self:
|
||||
if period.date_end < period.date_start:
|
||||
raise ValidationError(_('Period end date must be after start date.'))
|
||||
# Check for overlaps within same company and schedule type
|
||||
overlapping = self.search([
|
||||
('id', '!=', period.id),
|
||||
('company_id', '=', period.company_id.id),
|
||||
('schedule_type', '=', period.schedule_type),
|
||||
('date_start', '<=', period.date_end),
|
||||
('date_end', '>=', period.date_start),
|
||||
])
|
||||
if overlapping:
|
||||
raise ValidationError(_('Pay periods cannot overlap. Found overlap with: %s') % overlapping[0].name)
|
||||
|
||||
@api.model
|
||||
def generate_periods(self, company_id, schedule_type, start_date, num_periods=12, pay_day_offset=7):
|
||||
"""
|
||||
Generate pay periods based on schedule type.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
schedule_type: weekly, biweekly, semi_monthly, monthly
|
||||
start_date: First period start date
|
||||
num_periods: Number of periods to generate
|
||||
pay_day_offset: Days after period end to set pay date
|
||||
"""
|
||||
periods = []
|
||||
current_start = start_date
|
||||
|
||||
for _ in range(num_periods):
|
||||
if schedule_type == 'weekly':
|
||||
current_end = current_start + timedelta(days=6)
|
||||
next_start = current_start + timedelta(days=7)
|
||||
elif schedule_type == 'biweekly':
|
||||
current_end = current_start + timedelta(days=13)
|
||||
next_start = current_start + timedelta(days=14)
|
||||
elif schedule_type == 'semi_monthly':
|
||||
if current_start.day <= 15:
|
||||
current_end = current_start.replace(day=15)
|
||||
next_start = current_start.replace(day=16)
|
||||
else:
|
||||
# End of month
|
||||
next_month = current_start + relativedelta(months=1, day=1)
|
||||
current_end = next_month - timedelta(days=1)
|
||||
next_start = next_month
|
||||
elif schedule_type == 'monthly':
|
||||
next_month = current_start + relativedelta(months=1, day=1)
|
||||
current_end = next_month - timedelta(days=1)
|
||||
next_start = next_month
|
||||
else:
|
||||
raise ValidationError(_('Unknown schedule type: %s') % schedule_type)
|
||||
|
||||
pay_date = current_end + timedelta(days=pay_day_offset)
|
||||
|
||||
# Check if period already exists
|
||||
existing = self.search([
|
||||
('company_id', '=', company_id),
|
||||
('schedule_type', '=', schedule_type),
|
||||
('date_start', '=', current_start),
|
||||
], limit=1)
|
||||
|
||||
if not existing:
|
||||
period = self.create({
|
||||
'company_id': company_id,
|
||||
'schedule_type': schedule_type,
|
||||
'date_start': current_start,
|
||||
'date_end': current_end,
|
||||
'pay_date': pay_date,
|
||||
})
|
||||
periods.append(period)
|
||||
|
||||
current_start = next_start
|
||||
|
||||
return periods
|
||||
|
||||
@api.model
|
||||
def get_current_period(self, company_id, schedule_type):
|
||||
"""Get the current active pay period."""
|
||||
today = fields.Date.context_today(self)
|
||||
period = self.search([
|
||||
('company_id', '=', company_id),
|
||||
('schedule_type', '=', schedule_type),
|
||||
('date_start', '<=', today),
|
||||
('date_end', '>=', today),
|
||||
], limit=1)
|
||||
return period
|
||||
|
||||
@api.model
|
||||
def get_available_periods(self, company_id, schedule_type, include_past_months=6, include_future_months=6):
|
||||
"""
|
||||
Get list of available pay periods for selection.
|
||||
Ordered: Current period first, then future periods, then past periods.
|
||||
"""
|
||||
today = fields.Date.context_today(self)
|
||||
|
||||
# Calculate date range
|
||||
past_date = today - relativedelta(months=include_past_months)
|
||||
future_date = today + relativedelta(months=include_future_months)
|
||||
|
||||
# Get all periods within range
|
||||
all_periods = self.search([
|
||||
('company_id', '=', company_id),
|
||||
('schedule_type', '=', schedule_type),
|
||||
('date_start', '>=', past_date),
|
||||
('date_end', '<=', future_date),
|
||||
])
|
||||
|
||||
# Separate into current, future, and past
|
||||
current = all_periods.filtered(lambda p: p.date_start <= today <= p.date_end)
|
||||
future = all_periods.filtered(lambda p: p.date_start > today).sorted('date_start')
|
||||
past = all_periods.filtered(lambda p: p.date_end < today).sorted('date_start', reverse=True)
|
||||
|
||||
# Combine: current first, then future (ascending), then past (descending)
|
||||
return current + future + past
|
||||
|
||||
@api.model
|
||||
def auto_generate_periods_if_needed(self, company_id, schedule_type):
|
||||
"""
|
||||
Automatically generate pay periods for past 6 months and future 6 months if not exist.
|
||||
"""
|
||||
settings = self.env['payroll.pay.period.settings'].get_or_create_settings(company_id)
|
||||
today = fields.Date.context_today(self)
|
||||
|
||||
# Calculate how far back and forward we need
|
||||
past_date = today - relativedelta(months=6)
|
||||
future_date = today + relativedelta(months=6)
|
||||
|
||||
# Find the first period start date aligned to schedule
|
||||
first_start = settings.first_period_start
|
||||
|
||||
# If first_period_start is in the future, work backwards
|
||||
if first_start > past_date:
|
||||
# Calculate periods going backwards
|
||||
periods_to_generate = []
|
||||
current_start = first_start
|
||||
|
||||
# Go backwards to cover past 6 months
|
||||
while current_start > past_date:
|
||||
if schedule_type == 'weekly':
|
||||
current_start = current_start - timedelta(days=7)
|
||||
elif schedule_type == 'biweekly':
|
||||
current_start = current_start - timedelta(days=14)
|
||||
elif schedule_type == 'semi_monthly':
|
||||
if current_start.day <= 15:
|
||||
# Go to previous month's 16th
|
||||
prev_month = current_start - relativedelta(months=1)
|
||||
current_start = prev_month.replace(day=16)
|
||||
else:
|
||||
current_start = current_start.replace(day=1)
|
||||
elif schedule_type == 'monthly':
|
||||
current_start = current_start - relativedelta(months=1)
|
||||
|
||||
first_start = current_start
|
||||
|
||||
# Now generate from that start date forward
|
||||
periods_needed = 0
|
||||
temp_start = first_start
|
||||
while temp_start <= future_date:
|
||||
periods_needed += 1
|
||||
if schedule_type == 'weekly':
|
||||
temp_start = temp_start + timedelta(days=7)
|
||||
elif schedule_type == 'biweekly':
|
||||
temp_start = temp_start + timedelta(days=14)
|
||||
elif schedule_type == 'semi_monthly':
|
||||
if temp_start.day <= 15:
|
||||
temp_start = temp_start.replace(day=16)
|
||||
else:
|
||||
temp_start = temp_start + relativedelta(months=1, day=1)
|
||||
elif schedule_type == 'monthly':
|
||||
temp_start = temp_start + relativedelta(months=1)
|
||||
|
||||
# Generate the periods
|
||||
self.generate_periods(
|
||||
company_id=company_id,
|
||||
schedule_type=schedule_type,
|
||||
start_date=first_start,
|
||||
num_periods=periods_needed,
|
||||
pay_day_offset=settings.pay_day_offset,
|
||||
)
|
||||
|
||||
|
||||
class PayrollPayPeriodSettings(models.Model):
|
||||
"""
|
||||
Pay Period Settings per Company
|
||||
Stores the payroll schedule configuration.
|
||||
"""
|
||||
_name = 'payroll.pay.period.settings'
|
||||
_description = 'Pay Period Settings'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
ondelete='cascade',
|
||||
)
|
||||
|
||||
schedule_type = fields.Selection([
|
||||
('weekly', 'Weekly'),
|
||||
('biweekly', 'Bi-Weekly'),
|
||||
('semi_monthly', 'Semi-Monthly'),
|
||||
('monthly', 'Monthly'),
|
||||
], string='Pay Schedule', required=True, default='biweekly')
|
||||
|
||||
first_period_start = fields.Date(
|
||||
string='First Period Start',
|
||||
required=True,
|
||||
help='Start date of the first pay period',
|
||||
)
|
||||
|
||||
pay_day_offset = fields.Integer(
|
||||
string='Days Until Pay Date',
|
||||
default=7,
|
||||
help='Number of days after period end until pay date',
|
||||
)
|
||||
|
||||
auto_generate_periods = fields.Boolean(
|
||||
string='Auto-Generate Periods',
|
||||
default=True,
|
||||
help='Automatically generate future pay periods',
|
||||
)
|
||||
|
||||
periods_to_generate = fields.Integer(
|
||||
string='Periods to Generate',
|
||||
default=12,
|
||||
help='Number of future periods to keep generated',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_company', 'unique(company_id)', 'Only one pay period settings record per company.'),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def get_or_create_settings(self, company_id=None):
|
||||
"""Get or create settings for a company."""
|
||||
if not company_id:
|
||||
company_id = self.env.company.id
|
||||
|
||||
settings = self.search([('company_id', '=', company_id)], limit=1)
|
||||
if not settings:
|
||||
# Create with sensible defaults
|
||||
today = fields.Date.context_today(self)
|
||||
# Default to start of current bi-weekly period (Monday)
|
||||
first_start = today - timedelta(days=today.weekday())
|
||||
settings = self.create({
|
||||
'company_id': company_id,
|
||||
'schedule_type': 'biweekly',
|
||||
'first_period_start': first_start,
|
||||
})
|
||||
return settings
|
||||
|
||||
def action_generate_periods(self):
|
||||
"""Generate pay periods based on settings."""
|
||||
self.ensure_one()
|
||||
periods = self.env['payroll.pay.period'].generate_periods(
|
||||
company_id=self.company_id.id,
|
||||
schedule_type=self.schedule_type,
|
||||
start_date=self.first_period_start,
|
||||
num_periods=self.periods_to_generate,
|
||||
pay_day_offset=self.pay_day_offset,
|
||||
)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Pay Periods Generated'),
|
||||
'message': _('%d pay periods have been generated.') % len(periods),
|
||||
'type': 'success',
|
||||
},
|
||||
}
|
||||
46
fusion_payroll/models/payroll_accounting_mapping.py
Normal file
46
fusion_payroll/models/payroll_accounting_mapping.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class PayrollAccountingMapping(models.Model):
|
||||
"""
|
||||
Payroll Accounting Mapping
|
||||
Maps payroll items to chart of accounts.
|
||||
Only available if account module is installed.
|
||||
"""
|
||||
_name = 'payroll.accounting.mapping'
|
||||
_description = 'Payroll Accounting Mapping'
|
||||
_rec_name = 'payroll_item'
|
||||
|
||||
config_id = fields.Many2one(
|
||||
'payroll.config.settings',
|
||||
string='Payroll Settings',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
related='config_id.company_id',
|
||||
string='Company',
|
||||
store=True,
|
||||
)
|
||||
payroll_item = fields.Selection([
|
||||
('bank_account', 'Bank Account'),
|
||||
('wage_expense', 'Wage Expenses'),
|
||||
('employer_tax_expense', 'Employer Tax Expenses'),
|
||||
('federal_tax_liability', 'Federal Tax Liability'),
|
||||
('ontario_tax_liability', 'Ontario Tax Liability'),
|
||||
('vacation_pay_liability', 'Vacation Pay Liability'),
|
||||
], string='Payroll Item', required=True)
|
||||
account_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Chart of Accounts',
|
||||
required=True,
|
||||
domain="[('company_id', '=', company_id)]",
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_payroll_item_per_config',
|
||||
'unique(config_id, payroll_item)',
|
||||
'Each payroll item can only be mapped once per configuration.'),
|
||||
]
|
||||
614
fusion_payroll/models/payroll_cheque.py
Normal file
614
fusion_payroll/models/payroll_cheque.py
Normal file
@@ -0,0 +1,614 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Payroll Cheque Management
|
||||
=========================
|
||||
Custom cheque printing for payroll with configurable layouts.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from num2words import num2words
|
||||
|
||||
|
||||
class PayrollCheque(models.Model):
|
||||
"""
|
||||
Payroll Cheque Record
|
||||
Tracks cheques issued for payroll payments.
|
||||
"""
|
||||
_name = 'payroll.cheque'
|
||||
_description = 'Payroll Cheque'
|
||||
_order = 'cheque_date desc, cheque_number desc'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
compute='_compute_name',
|
||||
store=True,
|
||||
)
|
||||
cheque_number = fields.Char(
|
||||
string='Cheque Number',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
tracking=True,
|
||||
)
|
||||
cheque_date = fields.Date(
|
||||
string='Cheque Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# Payee Information
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
payment_method_display = fields.Char(
|
||||
string='Payment Method',
|
||||
compute='_compute_payment_method_display',
|
||||
)
|
||||
payee_name = fields.Char(
|
||||
string='Payee Name',
|
||||
compute='_compute_payee_info',
|
||||
store=True,
|
||||
)
|
||||
payee_address = fields.Text(
|
||||
string='Payee Address',
|
||||
compute='_compute_payee_info',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# Amount
|
||||
amount = fields.Monetary(
|
||||
string='Amount',
|
||||
currency_field='currency_id',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
amount_in_words = fields.Char(
|
||||
string='Amount in Words',
|
||||
compute='_compute_amount_in_words',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
|
||||
# Related Records
|
||||
payslip_id = fields.Many2one(
|
||||
'hr.payslip',
|
||||
string='Payslip',
|
||||
ondelete='set null',
|
||||
)
|
||||
payslip_run_id = fields.Many2one(
|
||||
'hr.payslip.run',
|
||||
string='Payslip Batch',
|
||||
ondelete='set null',
|
||||
)
|
||||
|
||||
# Bank Account
|
||||
bank_account_id = fields.Many2one(
|
||||
'res.partner.bank',
|
||||
string='Bank Account',
|
||||
domain="[('company_id', '=', company_id)]",
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# Pay Period Info
|
||||
pay_period_start = fields.Date(string='Pay Period Start')
|
||||
pay_period_end = fields.Date(string='Pay Period End')
|
||||
pay_period_display = fields.Char(
|
||||
string='Pay Period',
|
||||
compute='_compute_pay_period_display',
|
||||
)
|
||||
|
||||
# Memo
|
||||
memo = fields.Text(string='Memo')
|
||||
|
||||
# Status
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('printed', 'Printed'),
|
||||
('voided', 'Voided'),
|
||||
('cashed', 'Cashed'),
|
||||
], string='Status', default='draft', tracking=True)
|
||||
|
||||
printed_date = fields.Datetime(string='Printed Date', readonly=True)
|
||||
voided_date = fields.Datetime(string='Voided Date', readonly=True)
|
||||
void_reason = fields.Text(string='Void Reason')
|
||||
|
||||
# ==================== COMPUTED FIELDS ====================
|
||||
|
||||
@api.depends('cheque_number', 'employee_id')
|
||||
def _compute_name(self):
|
||||
for cheque in self:
|
||||
if cheque.cheque_number and cheque.employee_id:
|
||||
cheque.name = f"CHQ-{cheque.cheque_number} - {cheque.employee_id.name}"
|
||||
elif cheque.cheque_number:
|
||||
cheque.name = f"CHQ-{cheque.cheque_number}"
|
||||
else:
|
||||
cheque.name = _('New Cheque')
|
||||
|
||||
@api.depends('employee_id')
|
||||
def _compute_payee_info(self):
|
||||
for cheque in self:
|
||||
if cheque.employee_id:
|
||||
emp = cheque.employee_id
|
||||
cheque.payee_name = emp.name
|
||||
|
||||
# Build address from Fusion Payroll fields or fallback
|
||||
address_parts = []
|
||||
|
||||
# Try Fusion Payroll home address fields
|
||||
if hasattr(emp, 'home_street') and emp.home_street:
|
||||
address_parts.append(emp.home_street)
|
||||
if hasattr(emp, 'home_street2') and emp.home_street2:
|
||||
address_parts.append(emp.home_street2)
|
||||
|
||||
city_line = []
|
||||
if hasattr(emp, 'home_city') and emp.home_city:
|
||||
city_line.append(emp.home_city)
|
||||
if hasattr(emp, 'home_province') and emp.home_province:
|
||||
city_line.append(emp.home_province)
|
||||
if hasattr(emp, 'home_postal_code') and emp.home_postal_code:
|
||||
city_line.append(emp.home_postal_code)
|
||||
|
||||
if city_line:
|
||||
address_parts.append(' '.join(city_line))
|
||||
|
||||
# Fallback to private address
|
||||
if not address_parts and emp.private_street:
|
||||
address_parts.append(emp.private_street)
|
||||
if emp.private_city:
|
||||
address_parts.append(f"{emp.private_city}, {emp.private_state_id.code or ''} {emp.private_zip or ''}")
|
||||
|
||||
cheque.payee_address = '\n'.join(address_parts) if address_parts else ''
|
||||
else:
|
||||
cheque.payee_name = ''
|
||||
cheque.payee_address = ''
|
||||
|
||||
@api.depends('amount', 'currency_id')
|
||||
def _compute_amount_in_words(self):
|
||||
for cheque in self:
|
||||
if cheque.amount:
|
||||
# Split into dollars and cents
|
||||
dollars = int(cheque.amount)
|
||||
cents = int(round((cheque.amount - dollars) * 100))
|
||||
|
||||
# Convert to words
|
||||
dollars_words = num2words(dollars, lang='en').title()
|
||||
|
||||
# Format: "One Thousand Three Hundred Fifty-Three and 47/100"
|
||||
cheque.amount_in_words = f"*****{dollars_words} and {cents:02d}/100"
|
||||
else:
|
||||
cheque.amount_in_words = ''
|
||||
|
||||
@api.depends('pay_period_start', 'pay_period_end')
|
||||
def _compute_pay_period_display(self):
|
||||
for cheque in self:
|
||||
if cheque.pay_period_start and cheque.pay_period_end:
|
||||
cheque.pay_period_display = f"{cheque.pay_period_start.strftime('%m.%d.%Y')} - {cheque.pay_period_end.strftime('%m.%d.%Y')}"
|
||||
else:
|
||||
cheque.pay_period_display = ''
|
||||
|
||||
@api.depends('employee_id', 'employee_id.payment_method')
|
||||
def _compute_payment_method_display(self):
|
||||
for cheque in self:
|
||||
if cheque.employee_id and hasattr(cheque.employee_id, 'payment_method'):
|
||||
method = cheque.employee_id.payment_method
|
||||
if method == 'cheque':
|
||||
cheque.payment_method_display = 'Cheque'
|
||||
elif method == 'direct_deposit':
|
||||
cheque.payment_method_display = 'Direct Deposit'
|
||||
else:
|
||||
cheque.payment_method_display = method or 'N/A'
|
||||
else:
|
||||
cheque.payment_method_display = 'N/A'
|
||||
|
||||
# ==================== ACTIONS ====================
|
||||
|
||||
def action_assign_number(self):
|
||||
"""Assign cheque number from sequence, checking for highest existing number."""
|
||||
for cheque in self:
|
||||
if not cheque.cheque_number:
|
||||
# Get the highest cheque number from payroll cheques
|
||||
max_payroll = self.env['payroll.cheque'].search([
|
||||
('cheque_number', '!=', False),
|
||||
('cheque_number', '!=', ''),
|
||||
('company_id', '=', cheque.company_id.id),
|
||||
], order='cheque_number desc', limit=1)
|
||||
|
||||
# Get the highest cheque number from account.payment (vendor payments)
|
||||
max_payment = 0
|
||||
if 'account.payment' in self.env:
|
||||
payments = self.env['account.payment'].search([
|
||||
('check_number', '!=', False),
|
||||
('check_number', '!=', ''),
|
||||
('company_id', '=', cheque.company_id.id),
|
||||
])
|
||||
for payment in payments:
|
||||
try:
|
||||
num = int(payment.check_number)
|
||||
if num > max_payment:
|
||||
max_payment = num
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Get highest from payroll cheques
|
||||
max_payroll_num = 0
|
||||
if max_payroll and max_payroll.cheque_number:
|
||||
try:
|
||||
max_payroll_num = int(max_payroll.cheque_number)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Use the higher of the two, or sequence if both are 0
|
||||
next_num = max(max_payroll_num, max_payment) + 1
|
||||
|
||||
if next_num > 1:
|
||||
# Set sequence to next number
|
||||
sequence = self.env['ir.sequence'].search([
|
||||
('code', '=', 'payroll.cheque'),
|
||||
('company_id', '=', cheque.company_id.id),
|
||||
], limit=1)
|
||||
if sequence:
|
||||
sequence.write({'number_next': next_num})
|
||||
|
||||
cheque.cheque_number = str(next_num).zfill(6)
|
||||
else:
|
||||
# Use sequence normally
|
||||
cheque.cheque_number = self.env['ir.sequence'].next_by_code('payroll.cheque') or '/'
|
||||
|
||||
def action_print_cheque(self):
|
||||
"""Always open wizard to set/change cheque number before printing."""
|
||||
self.ensure_one()
|
||||
|
||||
# Always open wizard to allow changing the cheque number
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Set Cheque Number'),
|
||||
'res_model': 'payroll.cheque.number.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_cheque_id': self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_void(self):
|
||||
"""Void the cheque."""
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Void Cheque'),
|
||||
'res_model': 'payroll.cheque.void.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_cheque_id': self.id},
|
||||
}
|
||||
|
||||
def action_mark_cashed(self):
|
||||
"""Mark cheque as cashed."""
|
||||
self.write({'state': 'cashed'})
|
||||
|
||||
def action_reset_to_draft(self):
|
||||
"""Reset to draft (only for voided cheques)."""
|
||||
for cheque in self:
|
||||
if cheque.state == 'voided':
|
||||
cheque.write({
|
||||
'state': 'draft',
|
||||
'voided_date': False,
|
||||
'void_reason': False,
|
||||
})
|
||||
|
||||
# ==================== HELPER METHODS ====================
|
||||
|
||||
def get_pay_stub_data(self):
|
||||
"""Get pay stub data for the cheque report."""
|
||||
self.ensure_one()
|
||||
|
||||
payslip = self.payslip_id
|
||||
if not payslip:
|
||||
return {}
|
||||
|
||||
# Get payslip lines by category
|
||||
def get_line_amount(code):
|
||||
line = payslip.line_ids.filtered(lambda l: l.code == code)
|
||||
return line.total if line else 0
|
||||
|
||||
# Calculate YTD values
|
||||
year_start = self.cheque_date.replace(month=1, day=1)
|
||||
ytd_payslips = self.env['hr.payslip'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('date_from', '>=', year_start),
|
||||
('date_to', '<=', self.cheque_date),
|
||||
('state', 'in', ['done', 'paid']),
|
||||
])
|
||||
|
||||
def get_ytd_amount(code):
|
||||
total = 0
|
||||
for slip in ytd_payslips:
|
||||
line = slip.line_ids.filtered(lambda l: l.code == code)
|
||||
total += line.total if line else 0
|
||||
return total
|
||||
|
||||
# Get hourly rate
|
||||
hourly_rate = 0
|
||||
if hasattr(self.employee_id, 'hourly_rate'):
|
||||
hourly_rate = self.employee_id.hourly_rate or 0
|
||||
|
||||
# Calculate hours from payslip inputs
|
||||
regular_hours = 0
|
||||
for input_line in payslip.input_line_ids:
|
||||
if 'hour' in (input_line.code or '').lower():
|
||||
regular_hours = input_line.amount
|
||||
break
|
||||
|
||||
# Get regular pay - look for REGPAY first, then BASIC, then GROSS
|
||||
regular_pay_current = (get_line_amount('REGPAY') or get_line_amount('BASIC') or
|
||||
get_line_amount('GROSS') or payslip.basic_wage or 0)
|
||||
regular_pay_ytd = (get_ytd_amount('REGPAY') or get_ytd_amount('BASIC') or
|
||||
get_ytd_amount('GROSS') or 0)
|
||||
|
||||
# Get vacation pay
|
||||
vacation_pay_current = get_line_amount('VAC') or get_line_amount('VACATION') or 0
|
||||
vacation_pay_ytd = get_ytd_amount('VAC') or get_ytd_amount('VACATION') or 0
|
||||
|
||||
# Get stat holiday pay
|
||||
stat_pay_current = get_line_amount('STAT') or get_line_amount('STATHOLIDAY') or 0
|
||||
stat_pay_ytd = get_ytd_amount('STAT') or get_ytd_amount('STATHOLIDAY') or 0
|
||||
|
||||
# Get taxes - these are negative in payslip, so use abs()
|
||||
# First try to get from payslip lines
|
||||
income_tax_current = abs(get_line_amount('FIT') or get_line_amount('INCOMETAX') or 0)
|
||||
ei_current = abs(get_line_amount('EI_EMP') or get_line_amount('EI') or 0)
|
||||
cpp_current = abs(get_line_amount('CPP_EMP') or get_line_amount('CPP') or 0)
|
||||
cpp2_current = abs(get_line_amount('CPP2_EMP') or get_line_amount('CPP2') or 0)
|
||||
|
||||
# If individual line values are 0, calculate from payslip totals
|
||||
total_taxes_from_lines = income_tax_current + ei_current + cpp_current + cpp2_current
|
||||
if total_taxes_from_lines == 0 and payslip.basic_wage > 0 and payslip.net_wage > 0:
|
||||
# Calculate total taxes as difference between basic and net
|
||||
total_taxes_calculated = payslip.basic_wage - payslip.net_wage
|
||||
if total_taxes_calculated > 0:
|
||||
# Approximate breakdown based on typical Canadian tax rates
|
||||
# CPP ~5.95%, EI ~1.63%, Income Tax = remainder
|
||||
gross = payslip.basic_wage
|
||||
cpp_current = min(gross * 0.0595, 3867.50) # 2025 CPP max
|
||||
ei_current = min(gross * 0.0163, 1049.12) # 2025 EI max
|
||||
income_tax_current = max(0, total_taxes_calculated - cpp_current - ei_current)
|
||||
cpp2_current = 0 # Usually 0 unless over threshold
|
||||
|
||||
income_tax_ytd = abs(get_ytd_amount('FIT') or get_ytd_amount('INCOMETAX') or 0)
|
||||
ei_ytd = abs(get_ytd_amount('EI_EMP') or get_ytd_amount('EI') or 0)
|
||||
cpp_ytd = abs(get_ytd_amount('CPP_EMP') or get_ytd_amount('CPP') or 0)
|
||||
cpp2_ytd = abs(get_ytd_amount('CPP2_EMP') or get_ytd_amount('CPP2') or 0)
|
||||
|
||||
# Calculate totals
|
||||
total_taxes_current = income_tax_current + ei_current + cpp_current + cpp2_current
|
||||
total_taxes_ytd = income_tax_ytd + ei_ytd + cpp_ytd + cpp2_ytd
|
||||
|
||||
total_pay_current = regular_pay_current + vacation_pay_current + stat_pay_current
|
||||
total_pay_ytd = regular_pay_ytd + vacation_pay_ytd + stat_pay_ytd
|
||||
|
||||
# Get employer contributions
|
||||
employer_ei_current = abs(get_line_amount('EI_ER') or 0)
|
||||
employer_cpp_current = abs(get_line_amount('CPP_ER') or 0)
|
||||
employer_cpp2_current = abs(get_line_amount('CPP2_ER') or 0)
|
||||
|
||||
# If no employer lines, calculate from employee amounts
|
||||
if employer_ei_current == 0 and ei_current > 0:
|
||||
employer_ei_current = ei_current * 1.4 # EI employer is 1.4x employee
|
||||
if employer_cpp_current == 0 and cpp_current > 0:
|
||||
employer_cpp_current = cpp_current # CPP employer matches employee
|
||||
if employer_cpp2_current == 0 and cpp2_current > 0:
|
||||
employer_cpp2_current = cpp2_current # CPP2 employer matches employee
|
||||
|
||||
return {
|
||||
'pay': {
|
||||
'regular_pay': {
|
||||
'hours': regular_hours,
|
||||
'rate': hourly_rate,
|
||||
'current': regular_pay_current,
|
||||
'ytd': regular_pay_ytd,
|
||||
},
|
||||
'vacation_pay': {
|
||||
'hours': '-',
|
||||
'rate': '-',
|
||||
'current': vacation_pay_current,
|
||||
'ytd': vacation_pay_ytd,
|
||||
},
|
||||
'stat_holiday_pay': {
|
||||
'hours': '-',
|
||||
'rate': hourly_rate,
|
||||
'current': stat_pay_current,
|
||||
'ytd': stat_pay_ytd,
|
||||
},
|
||||
},
|
||||
'taxes': {
|
||||
'income_tax': {
|
||||
'current': income_tax_current,
|
||||
'ytd': income_tax_ytd,
|
||||
},
|
||||
'ei': {
|
||||
'current': ei_current,
|
||||
'ytd': ei_ytd,
|
||||
},
|
||||
'cpp': {
|
||||
'current': cpp_current,
|
||||
'ytd': cpp_ytd,
|
||||
},
|
||||
'cpp2': {
|
||||
'current': cpp2_current,
|
||||
'ytd': cpp2_ytd,
|
||||
},
|
||||
},
|
||||
'summary': {
|
||||
'total_pay': {
|
||||
'current': total_pay_current,
|
||||
'ytd': total_pay_ytd,
|
||||
},
|
||||
'taxes': {
|
||||
'current': total_taxes_current,
|
||||
'ytd': total_taxes_ytd,
|
||||
},
|
||||
'deductions': {
|
||||
'current': 0,
|
||||
'ytd': 0,
|
||||
},
|
||||
'net_pay': {
|
||||
'current': self.amount,
|
||||
'ytd': sum(s.net_wage for s in ytd_payslips) + self.amount,
|
||||
},
|
||||
},
|
||||
'benefits': {
|
||||
'vacation': {
|
||||
'accrued': 0,
|
||||
'used': 0,
|
||||
'available': 0,
|
||||
},
|
||||
},
|
||||
'employer': {
|
||||
'ei': {
|
||||
'current': employer_ei_current,
|
||||
},
|
||||
'cpp': {
|
||||
'current': employer_cpp_current,
|
||||
},
|
||||
'cpp2': {
|
||||
'current': employer_cpp2_current,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def create_from_payslip(self, payslip):
|
||||
"""Create a cheque from a payslip."""
|
||||
# Check if employee payment method is cheque
|
||||
if hasattr(payslip.employee_id, 'payment_method') and payslip.employee_id.payment_method != 'cheque':
|
||||
return False
|
||||
|
||||
# Check if cheque already exists for this payslip
|
||||
existing = self.search([('payslip_id', '=', payslip.id)], limit=1)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
cheque = self.create({
|
||||
'employee_id': payslip.employee_id.id,
|
||||
'payslip_id': payslip.id,
|
||||
'payslip_run_id': payslip.payslip_run_id.id if payslip.payslip_run_id else False,
|
||||
'amount': payslip.net_wage,
|
||||
'cheque_date': payslip.date_to,
|
||||
'pay_period_start': payslip.date_from,
|
||||
'pay_period_end': payslip.date_to,
|
||||
'company_id': payslip.company_id.id,
|
||||
})
|
||||
|
||||
# Link cheque back to payslip
|
||||
if cheque and hasattr(payslip, 'cheque_id'):
|
||||
payslip.cheque_id = cheque.id
|
||||
|
||||
return cheque
|
||||
|
||||
def action_mark_printed(self):
|
||||
"""Mark cheque as printed and assign number if not assigned."""
|
||||
for cheque in self:
|
||||
if not cheque.cheque_number:
|
||||
cheque.action_assign_number()
|
||||
cheque.state = 'printed'
|
||||
|
||||
|
||||
class PayrollChequeVoidWizard(models.TransientModel):
|
||||
"""Wizard to void a cheque with reason."""
|
||||
_name = 'payroll.cheque.void.wizard'
|
||||
_description = 'Void Cheque Wizard'
|
||||
|
||||
cheque_id = fields.Many2one('payroll.cheque', required=True)
|
||||
void_reason = fields.Text(string='Void Reason', required=True)
|
||||
|
||||
def action_void(self):
|
||||
"""Void the cheque."""
|
||||
self.cheque_id.write({
|
||||
'state': 'voided',
|
||||
'voided_date': fields.Datetime.now(),
|
||||
'void_reason': self.void_reason,
|
||||
})
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
|
||||
class PayrollChequeLayout(models.Model):
|
||||
"""
|
||||
Cheque Layout Configuration
|
||||
Allows customizing field positions on the cheque.
|
||||
"""
|
||||
_name = 'payroll.cheque.layout'
|
||||
_description = 'Cheque Layout'
|
||||
|
||||
name = fields.Char(string='Layout Name', required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# Page Settings
|
||||
page_width = fields.Float(string='Page Width (inches)', default=8.5)
|
||||
page_height = fields.Float(string='Page Height (inches)', default=11)
|
||||
|
||||
# Cheque Section (Top)
|
||||
cheque_height = fields.Float(string='Cheque Height (inches)', default=3.5)
|
||||
|
||||
# Date Position
|
||||
date_x = fields.Float(string='Date X Position', default=6.5)
|
||||
date_y = fields.Float(string='Date Y Position', default=0.5)
|
||||
date_format = fields.Selection([
|
||||
('mmddyyyy', 'MMDDYYYY'),
|
||||
('mm/dd/yyyy', 'MM/DD/YYYY'),
|
||||
('yyyy-mm-dd', 'YYYY-MM-DD'),
|
||||
], string='Date Format', default='mmddyyyy')
|
||||
|
||||
# Amount Position
|
||||
amount_x = fields.Float(string='Amount X Position', default=6.5)
|
||||
amount_y = fields.Float(string='Amount Y Position', default=1.5)
|
||||
|
||||
# Amount in Words Position
|
||||
amount_words_x = fields.Float(string='Amount Words X', default=0.5)
|
||||
amount_words_y = fields.Float(string='Amount Words Y', default=1.0)
|
||||
|
||||
# Payee Position
|
||||
payee_x = fields.Float(string='Payee X Position', default=0.5)
|
||||
payee_y = fields.Float(string='Payee Y Position', default=1.5)
|
||||
|
||||
# Memo Position
|
||||
memo_x = fields.Float(string='Memo X Position', default=0.5)
|
||||
memo_y = fields.Float(string='Memo Y Position', default=2.5)
|
||||
|
||||
# Pay Period Position
|
||||
pay_period_x = fields.Float(string='Pay Period X', default=0.5)
|
||||
pay_period_y = fields.Float(string='Pay Period Y', default=2.0)
|
||||
|
||||
# Stub Settings
|
||||
stub_height = fields.Float(string='Stub Height (inches)', default=3.75)
|
||||
show_employer_copy = fields.Boolean(string='Show Employer Copy', default=True)
|
||||
|
||||
@api.model
|
||||
def get_default_layout(self):
|
||||
"""Get or create the default layout."""
|
||||
layout = self.search([
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not layout:
|
||||
layout = self.create({
|
||||
'name': 'Default Cheque Layout',
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
|
||||
return layout
|
||||
546
fusion_payroll/models/payroll_config_settings.py
Normal file
546
fusion_payroll/models/payroll_config_settings.py
Normal file
@@ -0,0 +1,546 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
from datetime import date
|
||||
|
||||
|
||||
class PayrollConfigSettings(models.Model):
|
||||
"""
|
||||
Payroll Configuration Settings
|
||||
One record per company storing all payroll-related settings.
|
||||
"""
|
||||
_name = 'payroll.config.settings'
|
||||
_description = 'Payroll Configuration Settings'
|
||||
_rec_name = 'company_id'
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
ondelete='cascade',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Ensure we get or create settings for the current company."""
|
||||
res = super().default_get(fields_list)
|
||||
company_id = self.env.context.get('default_company_id') or self.env.company.id
|
||||
if 'company_id' in fields_list:
|
||||
res['company_id'] = company_id
|
||||
return res
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Ensure only one settings record per company."""
|
||||
records = self.browse()
|
||||
for vals in vals_list:
|
||||
company_id = vals.get('company_id') or self.env.company.id
|
||||
# Check if settings already exist for this company
|
||||
existing = self.search([('company_id', '=', company_id)], limit=1)
|
||||
if existing:
|
||||
# Update existing instead of creating new
|
||||
existing.write(vals)
|
||||
records |= existing
|
||||
else:
|
||||
# Create new record
|
||||
new_record = super(PayrollConfigSettings, self).create([vals])
|
||||
records |= new_record
|
||||
return records
|
||||
currency_id = fields.Many2one(
|
||||
related='company_id.currency_id',
|
||||
string='Currency',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# GENERAL TAX INFO
|
||||
# =========================================================================
|
||||
company_legal_name = fields.Char(
|
||||
string='Company Legal Name',
|
||||
help='Legal name of the company (may differ from trade/DBA name)',
|
||||
)
|
||||
company_legal_street = fields.Char(
|
||||
string='Street Address',
|
||||
help='Legal address street',
|
||||
)
|
||||
company_legal_street2 = fields.Char(
|
||||
string='Street Address 2',
|
||||
)
|
||||
company_legal_city = fields.Char(
|
||||
string='City',
|
||||
)
|
||||
company_legal_country_id = fields.Many2one(
|
||||
'res.country',
|
||||
string='Country',
|
||||
default=lambda self: self.env.ref('base.ca', raise_if_not_found=False),
|
||||
)
|
||||
company_legal_state_id = fields.Many2one(
|
||||
'res.country.state',
|
||||
string='Province',
|
||||
domain="[('country_id', '=?', company_legal_country_id)]",
|
||||
)
|
||||
|
||||
@api.onchange('company_legal_country_id')
|
||||
def _onchange_company_legal_country_id(self):
|
||||
"""Clear state when country changes."""
|
||||
if self.company_legal_country_id and self.company_legal_state_id:
|
||||
if self.company_legal_state_id.country_id != self.company_legal_country_id:
|
||||
self.company_legal_state_id = False
|
||||
company_legal_zip = fields.Char(
|
||||
string='Postal Code',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# CONTACT INFORMATION
|
||||
# =========================================================================
|
||||
payroll_contact_first_name = fields.Char(
|
||||
string='First Name',
|
||||
help='First name of the primary payroll contact',
|
||||
)
|
||||
payroll_contact_last_name = fields.Char(
|
||||
string='Last Name',
|
||||
help='Last name of the primary payroll contact',
|
||||
)
|
||||
payroll_contact_phone = fields.Char(
|
||||
string='Business Phone',
|
||||
help='Business phone number for payroll contact',
|
||||
)
|
||||
payroll_contact_email = fields.Char(
|
||||
string='Email Address',
|
||||
help='Email address for payroll contact (required for ROE and T4 forms)',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# FEDERAL TAX INFO
|
||||
# =========================================================================
|
||||
cra_business_number = fields.Char(
|
||||
string='CRA Business Number',
|
||||
help='Canada Revenue Agency Business Number',
|
||||
)
|
||||
cra_reference_number = fields.Char(
|
||||
string='Reference Number',
|
||||
help='CRA Reference Number (RP prefix)',
|
||||
default='0001',
|
||||
)
|
||||
cra_owner1_sin = fields.Char(
|
||||
string='SIN: Owner 1',
|
||||
help='Social Insurance Number for Owner 1',
|
||||
)
|
||||
cra_owner2_sin = fields.Char(
|
||||
string='SIN: Owner 2',
|
||||
help='Social Insurance Number for Owner 2 (optional)',
|
||||
)
|
||||
cra_representative_rac = fields.Char(
|
||||
string='Representative Identifier (RAC)',
|
||||
help='CRA Representative Authorization Code (optional)',
|
||||
)
|
||||
federal_tax_payment_frequency = fields.Selection([
|
||||
('monthly', 'Monthly'),
|
||||
('quarterly', 'Quarterly'),
|
||||
('annually', 'Annually'),
|
||||
], string='Payment Frequency', default='monthly')
|
||||
federal_tax_effective_date = fields.Date(
|
||||
string='Effective Date',
|
||||
help='Date when this payment frequency became effective',
|
||||
)
|
||||
federal_tax_form_type = fields.Char(
|
||||
string='Form Type',
|
||||
default='Form PD7A',
|
||||
help='CRA form type (e.g., Form PD7A)',
|
||||
)
|
||||
federal_tax_display = fields.Char(
|
||||
string='Current Schedule',
|
||||
compute='_compute_federal_tax_display',
|
||||
help='Display format: "Form PD7A, paying monthly since 01/01/2019"',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# PROVINCIAL TAX INFO (One2many to payment schedules)
|
||||
# =========================================================================
|
||||
provincial_tax_schedule_ids = fields.One2many(
|
||||
'payroll.tax.payment.schedule',
|
||||
'config_id',
|
||||
string='Provincial Tax Payment Schedules',
|
||||
help='Date-effective payment schedules by province',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# EMAIL NOTIFICATIONS
|
||||
# =========================================================================
|
||||
notification_email_primary_ids = fields.Many2many(
|
||||
'res.partner',
|
||||
'payroll_config_notification_primary_rel',
|
||||
'config_id',
|
||||
'partner_id',
|
||||
string='Primary Notification Recipients',
|
||||
help='Contacts who will receive primary payroll notifications',
|
||||
)
|
||||
notification_send_to_options = [
|
||||
('you', 'Send to you'),
|
||||
('accountants', 'Send to accountant(s)'),
|
||||
('both', 'Send to you and accountant(s)'),
|
||||
]
|
||||
notification_setup_send_to = fields.Selection(
|
||||
notification_send_to_options,
|
||||
string='Setup Notifications',
|
||||
default='both',
|
||||
)
|
||||
notification_form_filing_send_to = fields.Selection(
|
||||
notification_send_to_options,
|
||||
string='Form Filing Notifications',
|
||||
default='both',
|
||||
)
|
||||
notification_payday_send_to = fields.Selection(
|
||||
notification_send_to_options,
|
||||
string='Payday Notifications',
|
||||
default='both',
|
||||
)
|
||||
notification_tax_send_to = fields.Selection(
|
||||
notification_send_to_options,
|
||||
string='Tax Notifications',
|
||||
default='both',
|
||||
)
|
||||
notification_payday_reminders_send_to = fields.Selection(
|
||||
notification_send_to_options,
|
||||
string='Payday Reminders',
|
||||
default='both',
|
||||
)
|
||||
notification_tax_setup_reminders_send_to = fields.Selection(
|
||||
notification_send_to_options,
|
||||
string='Tax Setup Reminders',
|
||||
default='both',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# PRINTING PREFERENCES
|
||||
# =========================================================================
|
||||
print_preference = fields.Selection([
|
||||
('pay_stubs_only', 'Pay stubs only'),
|
||||
('paycheques_and_stubs', 'Paycheques and pay stubs on QuickBooks-compatible cheque paper'),
|
||||
], string='Print Preference', default='paycheques_and_stubs')
|
||||
paystub_layout = fields.Selection([
|
||||
('one_pay_stub', 'Paycheque and 1 pay stub'),
|
||||
('two_pay_stubs', 'Paycheque and 2 pay stubs'),
|
||||
], string='Paystub Layout',
|
||||
help='Number of pay stubs per paycheque',
|
||||
default='two_pay_stubs',
|
||||
)
|
||||
show_accrued_vacation_hours = fields.Boolean(
|
||||
string='Show Accrued Vacation Hours on Pay Stub',
|
||||
default=True,
|
||||
)
|
||||
show_accrued_vacation_balance = fields.Boolean(
|
||||
string='Show Accrued Vacation Balance on Pay Stub',
|
||||
default=False,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# DIRECT DEPOSIT
|
||||
# =========================================================================
|
||||
direct_deposit_funding_time = fields.Selection([
|
||||
('1-day', '1-day'),
|
||||
('2-day', '2-day'),
|
||||
('3-day', '3-day'),
|
||||
], string='Funding Time', default='2-day',
|
||||
help='Time for direct deposit funds to be available',
|
||||
)
|
||||
direct_deposit_funding_limit = fields.Monetary(
|
||||
string='Funding Limit',
|
||||
currency_field='currency_id',
|
||||
default=0.0,
|
||||
help='Maximum amount per payroll for direct deposit (0 = no limit)',
|
||||
)
|
||||
direct_deposit_funding_period_days = fields.Integer(
|
||||
string='Funding Period (Days)',
|
||||
default=6,
|
||||
help='Period in days for funding limit calculation',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# BANK ACCOUNTS
|
||||
# =========================================================================
|
||||
payroll_bank_account_id = fields.Many2one(
|
||||
'account.journal',
|
||||
string='Payroll Bank Account',
|
||||
domain="[('type', 'in', ('bank', 'cash')), ('company_id', '=', company_id)]",
|
||||
help='Bank or cash journal used for payroll payments',
|
||||
ondelete='set null',
|
||||
)
|
||||
|
||||
@api.onchange('company_id')
|
||||
def _onchange_company_id(self):
|
||||
"""Update journal and account domains when company changes."""
|
||||
if self.company_id:
|
||||
return {
|
||||
'domain': {
|
||||
'payroll_bank_account_id': [('type', 'in', ('bank', 'cash')), ('company_id', '=', self.company_id.id)],
|
||||
'account_bank_account_id': [('account_type', '=', 'asset_cash'), ('company_ids', 'parent_of', self.company_id.id)],
|
||||
'account_wage_expense_id': [('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', self.company_id.id)],
|
||||
'account_employer_tax_expense_id': [('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', self.company_id.id)],
|
||||
'account_federal_tax_liability_id': [('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', self.company_id.id)],
|
||||
'account_ontario_tax_liability_id': [('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', self.company_id.id)],
|
||||
'account_vacation_pay_liability_id': [('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', self.company_id.id)],
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'domain': {
|
||||
'payroll_bank_account_id': [('type', 'in', ('bank', 'cash'))],
|
||||
'account_bank_account_id': [('account_type', '=', 'asset_cash')],
|
||||
'account_wage_expense_id': [('account_type', 'in', ('expense', 'liability_current'))],
|
||||
'account_employer_tax_expense_id': [('account_type', 'in', ('expense', 'liability_current'))],
|
||||
'account_federal_tax_liability_id': [('account_type', 'in', ('expense', 'liability_current'))],
|
||||
'account_ontario_tax_liability_id': [('account_type', 'in', ('expense', 'liability_current'))],
|
||||
'account_vacation_pay_liability_id': [('account_type', 'in', ('expense', 'liability_current'))],
|
||||
}
|
||||
}
|
||||
payroll_principal_officer_info = fields.Text(
|
||||
string='Principal Officer Information',
|
||||
help='Information about the principal officer for payroll',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# WORKERS' COMPENSATION
|
||||
# =========================================================================
|
||||
workers_comp_province = fields.Selection([
|
||||
('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'),
|
||||
], string='Province', help='Province for workers\' compensation')
|
||||
workers_comp_class = fields.Char(
|
||||
string='Workers\' Comp Class',
|
||||
help='Workers\' compensation class code',
|
||||
)
|
||||
workers_comp_account_number = fields.Char(
|
||||
string='Workers\' Comp Account Number',
|
||||
help='Workers\' compensation account number',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# ACCOUNTING PREFERENCES (Optional - requires account module)
|
||||
# =========================================================================
|
||||
account_bank_account_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Paycheque and Payroll Tax Payments',
|
||||
domain="[('account_type', '=', 'asset_cash'), ('company_ids', 'parent_of', company_id)]",
|
||||
help='Bank and cash account in chart of accounts for payroll',
|
||||
ondelete='set null',
|
||||
)
|
||||
account_wage_expense_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Wage Expenses',
|
||||
domain="[('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', company_id)]",
|
||||
help='Account for wage expenses (expense or current liability)',
|
||||
ondelete='set null',
|
||||
)
|
||||
account_employer_tax_expense_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Employer Tax Expenses',
|
||||
domain="[('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', company_id)]",
|
||||
help='Account for employer tax expenses (expense or current liability)',
|
||||
ondelete='set null',
|
||||
)
|
||||
account_federal_tax_liability_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Federal Tax Liability',
|
||||
domain="[('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', company_id)]",
|
||||
help='Account for federal tax liabilities (expense or current liability)',
|
||||
ondelete='set null',
|
||||
)
|
||||
account_ontario_tax_liability_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Ontario Tax Liability',
|
||||
domain="[('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', company_id)]",
|
||||
help='Account for Ontario tax liabilities (expense or current liability)',
|
||||
ondelete='set null',
|
||||
)
|
||||
account_vacation_pay_liability_id = fields.Many2one(
|
||||
'account.account',
|
||||
string='Vacation Pay Liability',
|
||||
domain="[('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', company_id)]",
|
||||
help='Account for vacation pay liabilities (expense or current liability)',
|
||||
ondelete='set null',
|
||||
)
|
||||
account_class_tracking = fields.Selection([
|
||||
('none', 'I don\'t use classes for payroll transactions'),
|
||||
('same', 'I use the same class for all employees'),
|
||||
('different', 'I use different classes for different employees'),
|
||||
], string='Class Tracking',
|
||||
default='none',
|
||||
help='How do you want to track classes for payroll transactions in QuickBooks?',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# AUTO PAYROLL
|
||||
# =========================================================================
|
||||
auto_payroll_enabled = fields.Boolean(
|
||||
string='Auto Payroll Enabled',
|
||||
default=False,
|
||||
compute='_compute_auto_payroll',
|
||||
help='Whether auto payroll is currently enabled (feature not implemented)',
|
||||
)
|
||||
auto_payroll_ineligibility_reason = fields.Char(
|
||||
string='Ineligibility Reason',
|
||||
compute='_compute_auto_payroll',
|
||||
help='Reason why auto payroll is not available',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# WORK LOCATIONS (One2many)
|
||||
# =========================================================================
|
||||
work_location_ids = fields.One2many(
|
||||
'payroll.work.location',
|
||||
'company_id',
|
||||
string='Work Locations',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# COMPUTED FIELDS
|
||||
# =========================================================================
|
||||
|
||||
@api.depends('federal_tax_form_type', 'federal_tax_payment_frequency', 'federal_tax_effective_date')
|
||||
def _compute_federal_tax_display(self):
|
||||
"""Format federal tax schedule display."""
|
||||
for record in self:
|
||||
if record.federal_tax_effective_date and record.federal_tax_payment_frequency:
|
||||
freq_map = {
|
||||
'monthly': 'monthly',
|
||||
'quarterly': 'quarterly',
|
||||
'annually': 'annually',
|
||||
}
|
||||
freq = freq_map.get(record.federal_tax_payment_frequency, '')
|
||||
form_type = record.federal_tax_form_type or 'Form PD7A'
|
||||
date_str = record.federal_tax_effective_date.strftime('%m/%d/%Y')
|
||||
record.federal_tax_display = f"{form_type}, paying {freq} since {date_str}"
|
||||
else:
|
||||
record.federal_tax_display = ''
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_auto_payroll(self):
|
||||
"""Compute auto payroll eligibility (currently always False)."""
|
||||
for record in self:
|
||||
# Feature not implemented yet
|
||||
record.auto_payroll_enabled = False
|
||||
# Check for employees enrolled (placeholder logic)
|
||||
employee_count = self.env['hr.employee'].search_count([
|
||||
('company_id', '=', record.company_id.id),
|
||||
])
|
||||
if employee_count == 0:
|
||||
record.auto_payroll_ineligibility_reason = 'No employees enrolled'
|
||||
else:
|
||||
record.auto_payroll_ineligibility_reason = 'Feature not yet implemented'
|
||||
|
||||
# =========================================================================
|
||||
# HELPER METHODS
|
||||
# =========================================================================
|
||||
|
||||
def get_payroll_contact_name(self):
|
||||
"""Return full name of payroll contact."""
|
||||
self.ensure_one()
|
||||
parts = []
|
||||
if self.payroll_contact_first_name:
|
||||
parts.append(self.payroll_contact_first_name)
|
||||
if self.payroll_contact_last_name:
|
||||
parts.append(self.payroll_contact_last_name)
|
||||
return ' '.join(parts) if parts else ''
|
||||
|
||||
def get_primary_notification_emails(self):
|
||||
"""Return comma-separated list of emails from primary notification recipients."""
|
||||
self.ensure_one()
|
||||
emails = []
|
||||
for partner in self.notification_email_primary_ids:
|
||||
if partner.email:
|
||||
emails.append(partner.email)
|
||||
return ', '.join(emails) if emails else ''
|
||||
|
||||
def get_primary_notification_partners(self):
|
||||
"""Return recordset of partners who should receive primary notifications."""
|
||||
self.ensure_one()
|
||||
return self.notification_email_primary_ids.filtered(lambda p: p.email)
|
||||
|
||||
def get_cra_payroll_account_number(self):
|
||||
"""Return formatted CRA payroll account number."""
|
||||
self.ensure_one()
|
||||
if self.cra_business_number and self.cra_reference_number:
|
||||
return f"{self.cra_business_number}RP{self.cra_reference_number.zfill(4)}"
|
||||
return ''
|
||||
|
||||
def get_current_tax_schedule(self, province, check_date=None):
|
||||
"""Get current tax payment schedule for a province."""
|
||||
self.ensure_one()
|
||||
if not check_date:
|
||||
check_date = date.today()
|
||||
|
||||
schedule = self.provincial_tax_schedule_ids.filtered(
|
||||
lambda s: s.province == province and s.is_current
|
||||
)
|
||||
return schedule[0] if schedule else False
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_company', 'unique(company_id)', 'Only one payroll settings record is allowed per company.'),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def get_settings(self, company_id=None):
|
||||
"""Get or create settings for a company."""
|
||||
if not company_id:
|
||||
company_id = self.env.company.id
|
||||
|
||||
settings = self.search([('company_id', '=', company_id)], limit=1)
|
||||
if not settings:
|
||||
settings = self.create({'company_id': company_id})
|
||||
return settings
|
||||
|
||||
def action_save(self):
|
||||
"""Save settings."""
|
||||
self.ensure_one()
|
||||
# Settings are auto-saved, this is just for the button
|
||||
return True
|
||||
|
||||
def action_edit_federal_tax(self):
|
||||
"""Toggle edit mode for federal tax fields."""
|
||||
self.ensure_one()
|
||||
# In a real implementation, this would toggle visibility of edit fields
|
||||
# For now, fields are always editable
|
||||
return True
|
||||
|
||||
def action_align_printer(self):
|
||||
"""Action for printer alignment (placeholder)."""
|
||||
self.ensure_one()
|
||||
# This would typically open a wizard or generate a test print
|
||||
# For now, just return a notification
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Printer Alignment'),
|
||||
'message': _('Printer alignment feature will be implemented in a future update.'),
|
||||
'type': 'info',
|
||||
},
|
||||
}
|
||||
|
||||
def action_open_location(self):
|
||||
"""Open work location form (used from tree view)."""
|
||||
# This is called from the tree view button
|
||||
# The actual location opening is handled by the tree view's default behavior
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def name_get(self):
|
||||
"""Return display name with company."""
|
||||
result = []
|
||||
for record in self:
|
||||
name = f"Payroll Settings - {record.company_id.name}"
|
||||
result.append((record.id, name))
|
||||
return result
|
||||
379
fusion_payroll/models/payroll_dashboard.py
Normal file
379
fusion_payroll/models/payroll_dashboard.py
Normal file
@@ -0,0 +1,379 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Fusion Payroll Dashboard
|
||||
========================
|
||||
Modern dashboard with statistics, quick actions, and charts.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from datetime import date
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
class PayrollDashboard(models.Model):
|
||||
"""
|
||||
Dashboard model for Fusion Payroll.
|
||||
This is a singleton model that provides computed statistics.
|
||||
"""
|
||||
_name = 'fusion.payroll.dashboard'
|
||||
_description = 'Fusion Payroll Dashboard'
|
||||
|
||||
name = fields.Char(default='Fusion Payroll Dashboard')
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ==================== STATISTICS ====================
|
||||
|
||||
# Employee Stats
|
||||
total_employees = fields.Integer(
|
||||
string='Total Employees',
|
||||
compute='_compute_employee_stats',
|
||||
)
|
||||
active_employees = fields.Integer(
|
||||
string='Active Employees',
|
||||
compute='_compute_employee_stats',
|
||||
)
|
||||
on_leave_employees = fields.Integer(
|
||||
string='On Leave',
|
||||
compute='_compute_employee_stats',
|
||||
)
|
||||
terminated_employees = fields.Integer(
|
||||
string='Terminated',
|
||||
compute='_compute_employee_stats',
|
||||
)
|
||||
|
||||
# Payroll Stats
|
||||
payroll_this_month = fields.Monetary(
|
||||
string='Payroll This Month',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_payroll_stats',
|
||||
)
|
||||
payroll_this_year = fields.Monetary(
|
||||
string='Payroll This Year',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_payroll_stats',
|
||||
)
|
||||
payroll_last_month = fields.Monetary(
|
||||
string='Payroll Last Month',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_payroll_stats',
|
||||
)
|
||||
payroll_count_this_month = fields.Integer(
|
||||
string='Payslips This Month',
|
||||
compute='_compute_payroll_stats',
|
||||
)
|
||||
avg_payroll_per_employee = fields.Monetary(
|
||||
string='Avg Pay Per Employee',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_payroll_stats',
|
||||
)
|
||||
|
||||
# Trend indicators
|
||||
payroll_trend = fields.Float(
|
||||
string='Payroll Trend (%)',
|
||||
compute='_compute_payroll_stats',
|
||||
help='Percentage change from last month',
|
||||
)
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
|
||||
# ==================== COMPUTED METHODS ====================
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_employee_stats(self):
|
||||
for dashboard in self:
|
||||
Employee = self.env['hr.employee']
|
||||
company_domain = [('company_id', '=', dashboard.company_id.id)]
|
||||
|
||||
dashboard.total_employees = Employee.search_count(company_domain)
|
||||
|
||||
# Check if employment_status field exists
|
||||
if 'employment_status' in Employee._fields:
|
||||
dashboard.active_employees = Employee.search_count(
|
||||
company_domain + [('employment_status', '=', 'active')]
|
||||
)
|
||||
dashboard.on_leave_employees = Employee.search_count(
|
||||
company_domain + [('employment_status', '=', 'on_leave')]
|
||||
)
|
||||
dashboard.terminated_employees = Employee.search_count(
|
||||
company_domain + [('employment_status', '=', 'terminated')]
|
||||
)
|
||||
else:
|
||||
dashboard.active_employees = Employee.search_count(
|
||||
company_domain + [('active', '=', True)]
|
||||
)
|
||||
dashboard.on_leave_employees = 0
|
||||
dashboard.terminated_employees = Employee.search_count(
|
||||
company_domain + [('active', '=', False)]
|
||||
)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_payroll_stats(self):
|
||||
today = date.today()
|
||||
month_start = today.replace(day=1)
|
||||
year_start = today.replace(month=1, day=1)
|
||||
last_month_start = month_start - relativedelta(months=1)
|
||||
last_month_end = month_start - relativedelta(days=1)
|
||||
|
||||
for dashboard in self:
|
||||
Payslip = self.env['hr.payslip']
|
||||
company_domain = [
|
||||
('company_id', '=', dashboard.company_id.id),
|
||||
('state', 'in', ['done', 'paid']),
|
||||
]
|
||||
|
||||
# This month
|
||||
month_payslips = Payslip.search(
|
||||
company_domain + [
|
||||
('date_from', '>=', month_start),
|
||||
('date_to', '<=', today),
|
||||
]
|
||||
)
|
||||
dashboard.payroll_this_month = sum(month_payslips.mapped('net_wage'))
|
||||
dashboard.payroll_count_this_month = len(month_payslips)
|
||||
|
||||
# This year
|
||||
year_payslips = Payslip.search(
|
||||
company_domain + [
|
||||
('date_from', '>=', year_start),
|
||||
]
|
||||
)
|
||||
dashboard.payroll_this_year = sum(year_payslips.mapped('net_wage'))
|
||||
|
||||
# Last month (for trend)
|
||||
last_month_payslips = Payslip.search(
|
||||
company_domain + [
|
||||
('date_from', '>=', last_month_start),
|
||||
('date_to', '<=', last_month_end),
|
||||
]
|
||||
)
|
||||
dashboard.payroll_last_month = sum(last_month_payslips.mapped('net_wage'))
|
||||
|
||||
# Trend calculation
|
||||
if dashboard.payroll_last_month > 0:
|
||||
dashboard.payroll_trend = (
|
||||
(dashboard.payroll_this_month - dashboard.payroll_last_month)
|
||||
/ dashboard.payroll_last_month * 100
|
||||
)
|
||||
else:
|
||||
dashboard.payroll_trend = 0
|
||||
|
||||
# Average per employee
|
||||
if dashboard.active_employees > 0 and dashboard.payroll_this_month > 0:
|
||||
dashboard.avg_payroll_per_employee = (
|
||||
dashboard.payroll_this_month / dashboard.active_employees
|
||||
)
|
||||
else:
|
||||
dashboard.avg_payroll_per_employee = 0
|
||||
|
||||
# ==================== ACTION METHODS ====================
|
||||
|
||||
def action_run_payroll(self):
|
||||
"""Open Run Payroll wizard."""
|
||||
action = self.env['run.payroll.wizard'].action_open_run_payroll()
|
||||
# Ensure edit is enabled (override dashboard context)
|
||||
if action.get('context'):
|
||||
ctx = action['context']
|
||||
if isinstance(ctx, str):
|
||||
try:
|
||||
ctx = eval(ctx, {'uid': self.env.uid})
|
||||
except:
|
||||
ctx = {}
|
||||
if not isinstance(ctx, dict):
|
||||
ctx = {}
|
||||
else:
|
||||
ctx = {}
|
||||
ctx.update({'create': True, 'edit': True, 'delete': True})
|
||||
action['context'] = ctx
|
||||
return action
|
||||
|
||||
def _get_action_context(self, action):
|
||||
"""Parse action context and add create/edit/delete permissions."""
|
||||
ctx = action.get('context', {})
|
||||
if isinstance(ctx, str):
|
||||
try:
|
||||
ctx = eval(ctx, {'uid': self.env.uid, 'active_id': self.id})
|
||||
except:
|
||||
ctx = {}
|
||||
if not isinstance(ctx, dict):
|
||||
ctx = {}
|
||||
ctx.update({'create': True, 'edit': True, 'delete': True})
|
||||
return ctx
|
||||
|
||||
def action_view_employees(self):
|
||||
"""Open All Employees view using existing action."""
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('fusion_payroll.action_fusion_employees')
|
||||
action['context'] = self._get_action_context(action)
|
||||
return action
|
||||
|
||||
def action_cra_remittance(self):
|
||||
"""Open CRA Remittance view using existing action."""
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('fusion_payroll.action_fusion_tax_remittances')
|
||||
action['context'] = self._get_action_context(action)
|
||||
return action
|
||||
|
||||
def action_t4_slips(self):
|
||||
"""Open T4 Slips view using existing action."""
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('fusion_payroll.action_fusion_t4_slips')
|
||||
action['context'] = self._get_action_context(action)
|
||||
return action
|
||||
|
||||
def action_roe(self):
|
||||
"""Open Record of Employment view using existing action."""
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('fusion_payroll.action_fusion_roe')
|
||||
action['context'] = self._get_action_context(action)
|
||||
return action
|
||||
|
||||
def action_reports(self):
|
||||
"""Open Reports hub using existing action."""
|
||||
try:
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('fusion_payroll.action_fusion_report_hub_page')
|
||||
return action
|
||||
except ValueError:
|
||||
# Fallback if action doesn't exist
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Payroll Reports'),
|
||||
'res_model': 'ir.actions.report',
|
||||
'view_mode': 'kanban,list',
|
||||
'domain': [('model', 'like', 'hr.payslip')],
|
||||
}
|
||||
|
||||
def action_settings(self):
|
||||
"""Open Fusion Payroll Settings."""
|
||||
# Get or create settings record
|
||||
settings = self.env['payroll.config.settings'].get_settings()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Fusion Payroll Settings'),
|
||||
'res_model': 'payroll.config.settings',
|
||||
'res_id': settings.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'context': {'form_view_initial_mode': 'edit'},
|
||||
}
|
||||
|
||||
# ==================== SINGLETON PATTERN ====================
|
||||
|
||||
@api.model
|
||||
def get_dashboard(self):
|
||||
"""Get or create the dashboard singleton."""
|
||||
dashboard = self.search([
|
||||
('company_id', '=', self.env.company.id)
|
||||
], limit=1)
|
||||
|
||||
if not dashboard:
|
||||
dashboard = self.create({
|
||||
'name': 'Fusion Payroll Dashboard',
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
|
||||
return dashboard
|
||||
|
||||
@api.model
|
||||
def action_open_dashboard(self):
|
||||
"""Open the dashboard."""
|
||||
dashboard = self.get_dashboard()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Fusion Payroll'),
|
||||
'res_model': 'fusion.payroll.dashboard',
|
||||
'res_id': dashboard.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'flags': {'mode': 'readonly'},
|
||||
}
|
||||
|
||||
|
||||
class PayrollDashboardStats(models.Model):
|
||||
"""
|
||||
Monthly payroll statistics for graphing.
|
||||
This model stores aggregated monthly data for trend charts.
|
||||
"""
|
||||
_name = 'fusion.payroll.stats'
|
||||
_description = 'Payroll Statistics'
|
||||
_order = 'year desc, month desc'
|
||||
|
||||
name = fields.Char(compute='_compute_name', store=True)
|
||||
company_id = fields.Many2one('res.company', required=True)
|
||||
year = fields.Integer(required=True)
|
||||
month = fields.Integer(required=True)
|
||||
month_name = fields.Char(compute='_compute_name', store=True)
|
||||
|
||||
total_payroll = fields.Monetary(currency_field='currency_id')
|
||||
total_gross = fields.Monetary(currency_field='currency_id')
|
||||
total_taxes = fields.Monetary(currency_field='currency_id')
|
||||
employee_count = fields.Integer()
|
||||
payslip_count = fields.Integer()
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
|
||||
@api.depends('year', 'month')
|
||||
def _compute_name(self):
|
||||
month_names = [
|
||||
'', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
]
|
||||
for stat in self:
|
||||
if stat.month and stat.year:
|
||||
stat.month_name = month_names[stat.month]
|
||||
stat.name = f"{month_names[stat.month]} {stat.year}"
|
||||
else:
|
||||
stat.month_name = ''
|
||||
stat.name = 'New'
|
||||
|
||||
@api.model
|
||||
def refresh_stats(self):
|
||||
"""Refresh statistics from payslip data."""
|
||||
today = date.today()
|
||||
company = self.env.company
|
||||
|
||||
# Get data for last 12 months
|
||||
for i in range(12):
|
||||
target_date = today - relativedelta(months=i)
|
||||
year = target_date.year
|
||||
month = target_date.month
|
||||
|
||||
month_start = target_date.replace(day=1)
|
||||
month_end = (month_start + relativedelta(months=1)) - relativedelta(days=1)
|
||||
|
||||
# Find payslips for this month
|
||||
payslips = self.env['hr.payslip'].search([
|
||||
('company_id', '=', company.id),
|
||||
('state', 'in', ['done', 'paid']),
|
||||
('date_from', '>=', month_start),
|
||||
('date_to', '<=', month_end),
|
||||
])
|
||||
|
||||
# Find or create stat record
|
||||
stat = self.search([
|
||||
('company_id', '=', company.id),
|
||||
('year', '=', year),
|
||||
('month', '=', month),
|
||||
], limit=1)
|
||||
|
||||
vals = {
|
||||
'company_id': company.id,
|
||||
'year': year,
|
||||
'month': month,
|
||||
'total_payroll': sum(payslips.mapped('net_wage')),
|
||||
'total_gross': sum(payslips.mapped('basic_wage')) if 'basic_wage' in payslips._fields else 0,
|
||||
'total_taxes': 0, # Would need to sum tax lines
|
||||
'employee_count': len(set(payslips.mapped('employee_id.id'))),
|
||||
'payslip_count': len(payslips),
|
||||
}
|
||||
|
||||
if stat:
|
||||
stat.write(vals)
|
||||
else:
|
||||
self.create(vals)
|
||||
|
||||
return True
|
||||
485
fusion_payroll/models/payroll_entry.py
Normal file
485
fusion_payroll/models/payroll_entry.py
Normal file
@@ -0,0 +1,485 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from datetime import date, timedelta
|
||||
|
||||
|
||||
class PayrollEntry(models.TransientModel):
|
||||
"""
|
||||
Payroll Entry - Represents a single employee's payroll for a period.
|
||||
This is the QuickBooks-like payroll line item.
|
||||
Transient because it's linked to the wizard.
|
||||
"""
|
||||
_name = 'payroll.entry'
|
||||
_description = 'Payroll Entry'
|
||||
_order = 'employee_id'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'run.payroll.wizard',
|
||||
string='Payroll Wizard',
|
||||
ondelete='cascade',
|
||||
)
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
|
||||
# Display fields for Edit Paycheque dialog
|
||||
employee_address = fields.Char(
|
||||
string='Employee Address',
|
||||
compute='_compute_display_fields',
|
||||
)
|
||||
pay_date = fields.Date(
|
||||
string='Pay Date',
|
||||
related='wizard_id.pay_date',
|
||||
)
|
||||
pay_period_display = fields.Char(
|
||||
string='Pay Period',
|
||||
compute='_compute_display_fields',
|
||||
)
|
||||
paid_from = fields.Char(
|
||||
string='Paid From',
|
||||
compute='_compute_display_fields',
|
||||
)
|
||||
|
||||
# Employee info (computed for display)
|
||||
employee_type = fields.Selection([
|
||||
('hourly', 'Hourly'),
|
||||
('salary', 'Salary'),
|
||||
], string='Type', compute='_compute_employee_info')
|
||||
|
||||
hourly_rate = fields.Monetary(
|
||||
string='Hourly Rate',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_employee_info',
|
||||
)
|
||||
|
||||
# Pay components
|
||||
regular_hours = fields.Float(
|
||||
string='Regular Hours',
|
||||
default=0.0,
|
||||
)
|
||||
regular_pay = fields.Monetary(
|
||||
string='Regular Pay',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_pay_amounts',
|
||||
)
|
||||
|
||||
vacation_pay_percent = fields.Float(
|
||||
string='Vacation %',
|
||||
default=4.0,
|
||||
help='Vacation pay percentage (default 4%)',
|
||||
)
|
||||
vacation_pay = fields.Monetary(
|
||||
string='Vacation Pay',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_pay_amounts',
|
||||
)
|
||||
|
||||
stat_holiday_hours = fields.Float(
|
||||
string='Stat Holiday Hours',
|
||||
default=0.0,
|
||||
)
|
||||
stat_holiday_pay = fields.Monetary(
|
||||
string='Stat Holiday Pay',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_pay_amounts',
|
||||
)
|
||||
|
||||
stat_pay_avg_daily_wage = fields.Monetary(
|
||||
string='Stat Pay - Avg Daily Wage',
|
||||
currency_field='currency_id',
|
||||
default=0.0,
|
||||
help='Additional stat pay based on average daily wage',
|
||||
)
|
||||
|
||||
# Totals
|
||||
total_hours = fields.Float(
|
||||
string='Total Hrs',
|
||||
compute='_compute_totals',
|
||||
)
|
||||
gross_pay = fields.Monetary(
|
||||
string='Gross Pay',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_totals',
|
||||
)
|
||||
|
||||
# Deductions (Employee Taxes)
|
||||
income_tax = fields.Monetary(
|
||||
string='Income Tax',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_taxes',
|
||||
)
|
||||
employment_insurance = fields.Monetary(
|
||||
string='Employment Insurance',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_taxes',
|
||||
)
|
||||
cpp = fields.Monetary(
|
||||
string='Canada Pension Plan',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_taxes',
|
||||
)
|
||||
cpp2 = fields.Monetary(
|
||||
string='Second Canada Pension Plan',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_taxes',
|
||||
)
|
||||
total_employee_tax = fields.Monetary(
|
||||
string='Total Employee Tax',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_taxes',
|
||||
)
|
||||
|
||||
# Employer Taxes
|
||||
employer_ei = fields.Monetary(
|
||||
string='EI Employer',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_employer_taxes',
|
||||
)
|
||||
employer_cpp = fields.Monetary(
|
||||
string='CPP Employer',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_employer_taxes',
|
||||
)
|
||||
employer_cpp2 = fields.Monetary(
|
||||
string='CPP2 Employer',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_employer_taxes',
|
||||
)
|
||||
total_employer_tax = fields.Monetary(
|
||||
string='Total Employer Tax',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_employer_taxes',
|
||||
)
|
||||
|
||||
# Net Pay
|
||||
net_pay = fields.Monetary(
|
||||
string='Net Pay',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_net_pay',
|
||||
)
|
||||
|
||||
# Vacation Time Off Tracking
|
||||
vacation_hours_accrued = fields.Float(
|
||||
string='Vacation Hours Accrued',
|
||||
default=0.0,
|
||||
)
|
||||
vacation_hours_used = fields.Float(
|
||||
string='Vacation Hours Used',
|
||||
default=0.0,
|
||||
)
|
||||
vacation_hours_available = fields.Float(
|
||||
string='Vacation Hours Available',
|
||||
compute='_compute_vacation_balance',
|
||||
)
|
||||
vacation_amount_accrued = fields.Monetary(
|
||||
string='Vacation Amount Accrued',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_vacation_balance',
|
||||
)
|
||||
vacation_amount_used = fields.Monetary(
|
||||
string='Vacation Amount Used',
|
||||
currency_field='currency_id',
|
||||
default=0.0,
|
||||
)
|
||||
vacation_amount_available = fields.Monetary(
|
||||
string='Vacation Amount Available',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_vacation_balance',
|
||||
)
|
||||
|
||||
# Other fields
|
||||
memo = fields.Text(
|
||||
string='Memo',
|
||||
)
|
||||
payment_method = fields.Selection([
|
||||
('cheque', 'Paper cheque'),
|
||||
('direct_deposit', 'Direct Deposit'),
|
||||
], string='Pay Method', default='cheque')
|
||||
|
||||
# Currency
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
|
||||
# Previous payroll indicator
|
||||
previous_payroll_id = fields.Many2one(
|
||||
'hr.payslip',
|
||||
string='Previous Payroll',
|
||||
compute='_compute_previous_payroll',
|
||||
)
|
||||
previous_payroll_amount = fields.Monetary(
|
||||
string='Previous Amount',
|
||||
currency_field='currency_id',
|
||||
compute='_compute_previous_payroll',
|
||||
)
|
||||
previous_payroll_date = fields.Date(
|
||||
string='Previous Pay Date',
|
||||
compute='_compute_previous_payroll',
|
||||
)
|
||||
has_previous_payroll = fields.Boolean(
|
||||
string='Has Previous',
|
||||
compute='_compute_previous_payroll',
|
||||
)
|
||||
change_from_last_payroll = fields.Char(
|
||||
string='Change from Last',
|
||||
compute='_compute_previous_payroll',
|
||||
help='Percentage change from last payroll',
|
||||
)
|
||||
|
||||
@api.depends('employee_id')
|
||||
def _compute_employee_info(self):
|
||||
for entry in self:
|
||||
if entry.employee_id:
|
||||
employee = entry.employee_id
|
||||
|
||||
# Get pay type and rate from Fusion Payroll fields
|
||||
pay_type = getattr(employee, 'pay_type', 'hourly')
|
||||
|
||||
if pay_type == 'hourly':
|
||||
entry.employee_type = 'hourly'
|
||||
# Use hourly_rate from Fusion Payroll
|
||||
hourly_rate = getattr(employee, 'hourly_rate', 0.0)
|
||||
entry.hourly_rate = hourly_rate if hourly_rate else 0.0
|
||||
elif pay_type == 'salary':
|
||||
entry.employee_type = 'salary'
|
||||
# Calculate hourly from salary
|
||||
salary = getattr(employee, 'salary_amount', 0.0)
|
||||
hours_per_week = getattr(employee, 'default_hours_per_week', 40.0) or 40.0
|
||||
# Bi-weekly = salary / 2, then divide by hours per pay period
|
||||
entry.hourly_rate = (salary / 2) / hours_per_week if salary else 0.0
|
||||
else:
|
||||
entry.employee_type = 'hourly'
|
||||
entry.hourly_rate = 0.0
|
||||
|
||||
# If still no rate, fallback to contract or hourly_cost
|
||||
if entry.hourly_rate == 0.0:
|
||||
if hasattr(employee, 'hourly_cost') and employee.hourly_cost:
|
||||
entry.hourly_rate = employee.hourly_cost
|
||||
elif hasattr(employee, 'contract_id') and employee.contract_id:
|
||||
contract = employee.contract_id
|
||||
if hasattr(contract, 'wage') and contract.wage:
|
||||
entry.hourly_rate = contract.wage / 160
|
||||
else:
|
||||
entry.employee_type = 'hourly'
|
||||
entry.hourly_rate = 0.0
|
||||
|
||||
@api.depends('regular_hours', 'hourly_rate', 'vacation_pay_percent', 'stat_holiday_hours')
|
||||
def _compute_pay_amounts(self):
|
||||
for entry in self:
|
||||
# Regular pay = hours * rate
|
||||
entry.regular_pay = entry.regular_hours * entry.hourly_rate
|
||||
|
||||
# Vacation pay = vacation_pay_percent% of regular pay
|
||||
entry.vacation_pay = entry.regular_pay * (entry.vacation_pay_percent / 100)
|
||||
|
||||
# Stat holiday pay = hours * rate
|
||||
entry.stat_holiday_pay = entry.stat_holiday_hours * entry.hourly_rate
|
||||
|
||||
@api.depends('regular_hours', 'stat_holiday_hours', 'regular_pay', 'vacation_pay', 'stat_holiday_pay', 'stat_pay_avg_daily_wage')
|
||||
def _compute_totals(self):
|
||||
for entry in self:
|
||||
entry.total_hours = entry.regular_hours + entry.stat_holiday_hours
|
||||
entry.gross_pay = entry.regular_pay + entry.vacation_pay + entry.stat_holiday_pay + entry.stat_pay_avg_daily_wage
|
||||
|
||||
@api.depends('gross_pay', 'employee_id')
|
||||
def _compute_taxes(self):
|
||||
"""Calculate employee tax deductions."""
|
||||
for entry in self:
|
||||
if entry.gross_pay <= 0:
|
||||
entry.income_tax = 0
|
||||
entry.employment_insurance = 0
|
||||
entry.cpp = 0
|
||||
entry.cpp2 = 0
|
||||
entry.total_employee_tax = 0
|
||||
continue
|
||||
|
||||
# Get tax rates from parameters or use defaults
|
||||
# These are simplified calculations - actual payroll uses full tax rules
|
||||
gross = entry.gross_pay
|
||||
|
||||
# Simplified tax calculations (bi-weekly)
|
||||
# Income tax: ~15-20% average for Canadian employees
|
||||
entry.income_tax = round(gross * 0.128, 2) # Approximate federal + provincial
|
||||
|
||||
# EI: 1.64% of gross (2025 rate) up to maximum
|
||||
entry.employment_insurance = round(min(gross * 0.0164, 1049.12 / 26), 2)
|
||||
|
||||
# CPP: 5.95% of pensionable earnings above basic exemption (2025)
|
||||
cpp_exempt = 3500 / 26 # Annual exemption / 26 pay periods
|
||||
pensionable = max(0, gross - cpp_exempt)
|
||||
entry.cpp = round(min(pensionable * 0.0595, 4034.10 / 26), 2)
|
||||
|
||||
# CPP2: 4% on earnings above first ceiling (2025)
|
||||
entry.cpp2 = 0 # Only applies if earnings exceed $71,300/year
|
||||
|
||||
entry.total_employee_tax = entry.income_tax + entry.employment_insurance + entry.cpp + entry.cpp2
|
||||
|
||||
@api.depends('employment_insurance', 'cpp', 'cpp2')
|
||||
def _compute_employer_taxes(self):
|
||||
"""Calculate employer tax contributions."""
|
||||
for entry in self:
|
||||
# EI employer: 1.4x employee rate
|
||||
entry.employer_ei = round(entry.employment_insurance * 1.4, 2)
|
||||
|
||||
# CPP employer: same as employee
|
||||
entry.employer_cpp = entry.cpp
|
||||
|
||||
# CPP2 employer: same as employee
|
||||
entry.employer_cpp2 = entry.cpp2
|
||||
|
||||
entry.total_employer_tax = entry.employer_ei + entry.employer_cpp + entry.employer_cpp2
|
||||
|
||||
@api.depends('gross_pay', 'total_employee_tax')
|
||||
def _compute_net_pay(self):
|
||||
for entry in self:
|
||||
entry.net_pay = entry.gross_pay - entry.total_employee_tax
|
||||
|
||||
@api.depends('vacation_pay', 'vacation_hours_used')
|
||||
def _compute_vacation_balance(self):
|
||||
for entry in self:
|
||||
entry.vacation_hours_available = entry.vacation_hours_accrued - entry.vacation_hours_used
|
||||
entry.vacation_amount_accrued = entry.vacation_pay # Current period accrual
|
||||
entry.vacation_amount_available = entry.vacation_amount_accrued - entry.vacation_amount_used
|
||||
|
||||
@api.depends('employee_id', 'wizard_id.date_start', 'wizard_id.date_end', 'gross_pay')
|
||||
def _compute_previous_payroll(self):
|
||||
"""Check if employee has been paid in the current period and compute change."""
|
||||
for entry in self:
|
||||
entry.previous_payroll_id = False
|
||||
entry.previous_payroll_amount = 0
|
||||
entry.previous_payroll_date = False
|
||||
entry.has_previous_payroll = False
|
||||
entry.change_from_last_payroll = ''
|
||||
|
||||
if not entry.employee_id:
|
||||
continue
|
||||
|
||||
# Search for the last payslip for this employee (not in current period)
|
||||
payslip = self.env['hr.payslip'].search([
|
||||
('employee_id', '=', entry.employee_id.id),
|
||||
('state', 'in', ['done', 'paid']),
|
||||
], limit=1, order='date_to desc')
|
||||
|
||||
if payslip:
|
||||
entry.previous_payroll_id = payslip
|
||||
entry.previous_payroll_amount = payslip.net_wage
|
||||
entry.previous_payroll_date = payslip.date_to
|
||||
entry.has_previous_payroll = True
|
||||
|
||||
# Calculate change percentage
|
||||
if payslip.basic_wage and payslip.basic_wage > 0 and entry.gross_pay > 0:
|
||||
change = ((entry.gross_pay - payslip.basic_wage) / payslip.basic_wage) * 100
|
||||
if change > 0:
|
||||
entry.change_from_last_payroll = f"↑ Up {abs(change):.0f}%"
|
||||
elif change < 0:
|
||||
entry.change_from_last_payroll = f"↓ Down {abs(change):.0f}%"
|
||||
else:
|
||||
entry.change_from_last_payroll = "No change"
|
||||
|
||||
def action_load_attendance_hours(self):
|
||||
"""Load hours from hr_attendance for the pay period."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.wizard_id:
|
||||
return
|
||||
|
||||
# Search for attendance records in the period
|
||||
attendances = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('check_in', '>=', self.wizard_id.date_start),
|
||||
('check_in', '<=', self.wizard_id.date_end),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
|
||||
total_hours = 0
|
||||
for att in attendances:
|
||||
if att.check_out:
|
||||
delta = att.check_out - att.check_in
|
||||
total_hours += delta.total_seconds() / 3600
|
||||
|
||||
self.regular_hours = round(total_hours, 2)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Attendance Hours Loaded'),
|
||||
'message': _('Loaded %.2f hours from attendance records.') % total_hours,
|
||||
'type': 'success',
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_previous_payroll(self):
|
||||
"""Open the previous payroll record."""
|
||||
self.ensure_one()
|
||||
if self.previous_payroll_id:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Previous Payslip'),
|
||||
'res_model': 'hr.payslip',
|
||||
'res_id': self.previous_payroll_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_open_edit_paycheque(self):
|
||||
"""Open the detailed paycheque editor dialog."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Edit Paycheque - %s') % self.employee_id.name,
|
||||
'res_model': 'payroll.entry',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'form_view_ref': 'fusion_payroll.payroll_entry_edit_form'},
|
||||
}
|
||||
|
||||
@api.depends('employee_id', 'wizard_id')
|
||||
def _compute_display_fields(self):
|
||||
for entry in self:
|
||||
# Employee address
|
||||
if entry.employee_id:
|
||||
emp = entry.employee_id
|
||||
parts = []
|
||||
if hasattr(emp, 'home_street') and emp.home_street:
|
||||
parts.append(emp.home_street)
|
||||
if hasattr(emp, 'home_city') and emp.home_city:
|
||||
city_part = emp.home_city
|
||||
if hasattr(emp, 'home_province') and emp.home_province:
|
||||
city_part += f", {emp.home_province}"
|
||||
if hasattr(emp, 'home_postal_code') and emp.home_postal_code:
|
||||
city_part += f" {emp.home_postal_code}"
|
||||
parts.append(city_part)
|
||||
entry.employee_address = '\n'.join(parts) if parts else ''
|
||||
else:
|
||||
entry.employee_address = ''
|
||||
|
||||
# Pay period display
|
||||
if entry.wizard_id and entry.wizard_id.date_start and entry.wizard_id.date_end:
|
||||
entry.pay_period_display = f"{entry.wizard_id.date_start.strftime('%m.%d.%Y')} to {entry.wizard_id.date_end.strftime('%m.%d.%Y')}"
|
||||
else:
|
||||
entry.pay_period_display = ''
|
||||
|
||||
# Paid from (company bank or cheque info)
|
||||
if entry.wizard_id and entry.wizard_id.company_id:
|
||||
company = entry.wizard_id.company_id
|
||||
if entry.payment_method == 'cheque':
|
||||
entry.paid_from = f"Cheque ({company.name})"
|
||||
else:
|
||||
entry.paid_from = f"Direct Deposit ({company.name})"
|
||||
else:
|
||||
entry.paid_from = ''
|
||||
|
||||
def action_save_entry(self):
|
||||
"""Save the entry and close the dialog."""
|
||||
self.ensure_one()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
465
fusion_payroll/models/payroll_report.py
Normal file
465
fusion_payroll/models/payroll_report.py
Normal file
@@ -0,0 +1,465 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Payroll Report Framework
|
||||
========================
|
||||
Base abstract model for all payroll reports, modeled after Odoo's account_reports.
|
||||
Provides filter options, data fetching, and export functionality.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools import format_date, float_round
|
||||
from odoo.tools.misc import formatLang
|
||||
|
||||
|
||||
class PayrollReport(models.AbstractModel):
|
||||
"""
|
||||
Abstract base class for all payroll reports.
|
||||
Provides common filter options, column definitions, and export methods.
|
||||
"""
|
||||
_name = 'payroll.report'
|
||||
_description = 'Payroll Report Base'
|
||||
|
||||
# =========================================================================
|
||||
# REPORT CONFIGURATION (Override in concrete reports)
|
||||
# =========================================================================
|
||||
|
||||
# Filter options - set to True to enable
|
||||
filter_date_range = True
|
||||
filter_employee = True
|
||||
filter_department = False
|
||||
filter_pay_period = False
|
||||
|
||||
# Report metadata
|
||||
report_name = 'Payroll Report'
|
||||
report_code = 'payroll_report'
|
||||
|
||||
# Date filter presets matching QuickBooks
|
||||
DATE_FILTER_OPTIONS = [
|
||||
('last_pay_date', 'Last pay date'),
|
||||
('this_month', 'This month'),
|
||||
('this_quarter', 'This quarter'),
|
||||
('this_year', 'This year'),
|
||||
('last_month', 'Last month'),
|
||||
('last_quarter', 'Last quarter'),
|
||||
('last_year', 'Last year'),
|
||||
('first_quarter', 'First quarter'),
|
||||
('custom', 'Custom'),
|
||||
]
|
||||
|
||||
# =========================================================================
|
||||
# OPTIONS HANDLING
|
||||
# =========================================================================
|
||||
|
||||
def _get_default_options(self):
|
||||
"""Return default options for the report."""
|
||||
today = date.today()
|
||||
|
||||
options = {
|
||||
'report_name': self.report_name,
|
||||
'report_code': self.report_code,
|
||||
'date': {
|
||||
'filter': 'this_year',
|
||||
'date_from': today.replace(month=1, day=1).strftime('%Y-%m-%d'),
|
||||
'date_to': today.strftime('%Y-%m-%d'),
|
||||
},
|
||||
'columns': self._get_columns(),
|
||||
'currency_id': self.env.company.currency_id.id,
|
||||
}
|
||||
|
||||
if self.filter_employee:
|
||||
options['employee_ids'] = []
|
||||
options['all_employees'] = True
|
||||
|
||||
if self.filter_department:
|
||||
options['department_ids'] = []
|
||||
options['all_departments'] = True
|
||||
|
||||
return options
|
||||
|
||||
def _get_options(self, previous_options=None):
|
||||
"""
|
||||
Build report options, merging with previous options if provided.
|
||||
"""
|
||||
options = self._get_default_options()
|
||||
|
||||
if previous_options:
|
||||
# Merge date options
|
||||
if 'date' in previous_options:
|
||||
options['date'].update(previous_options['date'])
|
||||
|
||||
# Merge employee filter
|
||||
if 'employee_ids' in previous_options:
|
||||
options['employee_ids'] = previous_options['employee_ids']
|
||||
options['all_employees'] = not previous_options['employee_ids']
|
||||
|
||||
# Merge department filter
|
||||
if 'department_ids' in previous_options:
|
||||
options['department_ids'] = previous_options['department_ids']
|
||||
options['all_departments'] = not previous_options['department_ids']
|
||||
|
||||
# Apply date filter preset
|
||||
self._apply_date_filter(options)
|
||||
|
||||
return options
|
||||
|
||||
def _apply_date_filter(self, options):
|
||||
"""Calculate date_from and date_to based on filter preset."""
|
||||
today = date.today()
|
||||
date_filter = options.get('date', {}).get('filter', 'this_year')
|
||||
|
||||
if date_filter == 'custom':
|
||||
# Keep existing dates
|
||||
return
|
||||
|
||||
date_from = today
|
||||
date_to = today
|
||||
|
||||
if date_filter == 'last_pay_date':
|
||||
# Find the last payslip date
|
||||
last_payslip = self.env['hr.payslip'].search([
|
||||
('state', 'in', ['done', 'paid']),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], order='date_to desc', limit=1)
|
||||
if last_payslip:
|
||||
date_from = last_payslip.date_from
|
||||
date_to = last_payslip.date_to
|
||||
else:
|
||||
date_from = today
|
||||
date_to = today
|
||||
|
||||
elif date_filter == 'this_month':
|
||||
date_from = today.replace(day=1)
|
||||
date_to = (today.replace(day=1) + relativedelta(months=1)) - relativedelta(days=1)
|
||||
|
||||
elif date_filter == 'this_quarter':
|
||||
quarter_month = ((today.month - 1) // 3) * 3 + 1
|
||||
date_from = today.replace(month=quarter_month, day=1)
|
||||
date_to = (date_from + relativedelta(months=3)) - relativedelta(days=1)
|
||||
|
||||
elif date_filter == 'this_year':
|
||||
date_from = today.replace(month=1, day=1)
|
||||
date_to = today.replace(month=12, day=31)
|
||||
|
||||
elif date_filter == 'last_month':
|
||||
last_month = today - relativedelta(months=1)
|
||||
date_from = last_month.replace(day=1)
|
||||
date_to = today.replace(day=1) - relativedelta(days=1)
|
||||
|
||||
elif date_filter == 'last_quarter':
|
||||
quarter_month = ((today.month - 1) // 3) * 3 + 1
|
||||
last_quarter_start = today.replace(month=quarter_month, day=1) - relativedelta(months=3)
|
||||
date_from = last_quarter_start
|
||||
date_to = (last_quarter_start + relativedelta(months=3)) - relativedelta(days=1)
|
||||
|
||||
elif date_filter == 'last_year':
|
||||
date_from = today.replace(year=today.year - 1, month=1, day=1)
|
||||
date_to = today.replace(year=today.year - 1, month=12, day=31)
|
||||
|
||||
elif date_filter == 'first_quarter':
|
||||
date_from = today.replace(month=1, day=1)
|
||||
date_to = today.replace(month=3, day=31)
|
||||
|
||||
options['date']['date_from'] = date_from.strftime('%Y-%m-%d')
|
||||
options['date']['date_to'] = date_to.strftime('%Y-%m-%d')
|
||||
|
||||
# =========================================================================
|
||||
# COLUMN DEFINITIONS (Override in concrete reports)
|
||||
# =========================================================================
|
||||
|
||||
def _get_columns(self):
|
||||
"""
|
||||
Return column definitions for the report.
|
||||
Override in concrete reports.
|
||||
|
||||
Returns:
|
||||
list: List of column dictionaries with:
|
||||
- name: Display name
|
||||
- field: Data field name
|
||||
- type: 'char', 'date', 'monetary', 'float', 'integer'
|
||||
- sortable: bool
|
||||
- width: optional width class
|
||||
"""
|
||||
return [
|
||||
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
|
||||
]
|
||||
|
||||
# =========================================================================
|
||||
# DATA FETCHING (Override in concrete reports)
|
||||
# =========================================================================
|
||||
|
||||
def _get_domain(self, options):
|
||||
"""
|
||||
Build search domain from options.
|
||||
Override in concrete reports for specific filtering.
|
||||
"""
|
||||
domain = [('company_id', '=', self.env.company.id)]
|
||||
|
||||
# Date filter
|
||||
date_from = options.get('date', {}).get('date_from')
|
||||
date_to = options.get('date', {}).get('date_to')
|
||||
if date_from:
|
||||
domain.append(('date_from', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date_to', '<=', date_to))
|
||||
|
||||
# Employee filter
|
||||
if not options.get('all_employees') and options.get('employee_ids'):
|
||||
domain.append(('employee_id', 'in', options['employee_ids']))
|
||||
|
||||
# Department filter
|
||||
if not options.get('all_departments') and options.get('department_ids'):
|
||||
domain.append(('employee_id.department_id', 'in', options['department_ids']))
|
||||
|
||||
return domain
|
||||
|
||||
def _get_lines(self, options):
|
||||
"""
|
||||
Fetch and return report lines.
|
||||
Override in concrete reports.
|
||||
|
||||
Returns:
|
||||
list: List of line dictionaries with:
|
||||
- id: unique line id
|
||||
- name: display name
|
||||
- columns: list of column values
|
||||
- level: hierarchy level (0, 1, 2...)
|
||||
- unfoldable: bool for drill-down
|
||||
- unfolded: bool current state
|
||||
- class: CSS classes
|
||||
"""
|
||||
return []
|
||||
|
||||
def _get_report_data(self, options):
|
||||
"""
|
||||
Get complete report data for frontend.
|
||||
"""
|
||||
return {
|
||||
'options': options,
|
||||
'columns': self._get_columns(),
|
||||
'lines': self._get_lines(options),
|
||||
'date_filter_options': self.DATE_FILTER_OPTIONS,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# TOTALS AND AGGREGATIONS
|
||||
# =========================================================================
|
||||
|
||||
def _get_total_line(self, lines, options):
|
||||
"""
|
||||
Calculate totals from lines.
|
||||
Override for custom total calculations.
|
||||
"""
|
||||
if not lines:
|
||||
return {}
|
||||
|
||||
columns = self._get_columns()
|
||||
total_values = {}
|
||||
|
||||
for col in columns:
|
||||
if col.get('type') in ('monetary', 'float', 'integer'):
|
||||
field = col['field']
|
||||
try:
|
||||
total_values[field] = sum(
|
||||
float(line.get('values', {}).get(field, 0) or 0)
|
||||
for line in lines
|
||||
if line.get('level', 0) == 0 # Only sum top-level lines
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
total_values[field] = 0
|
||||
|
||||
return {
|
||||
'id': 'total',
|
||||
'name': _('Total'),
|
||||
'values': total_values,
|
||||
'level': -1, # Special level for total
|
||||
'class': 'o_payroll_report_total fw-bold',
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# SETTINGS HELPERS
|
||||
# =========================================================================
|
||||
|
||||
def _get_payroll_settings(self):
|
||||
"""Get payroll settings for current company."""
|
||||
return self.env['payroll.config.settings'].get_settings()
|
||||
|
||||
def _get_company_legal_info(self):
|
||||
"""Get company legal name and address from settings."""
|
||||
settings = self._get_payroll_settings()
|
||||
return {
|
||||
'legal_name': settings.company_legal_name or self.env.company.name,
|
||||
'legal_street': settings.company_legal_street or self.env.company.street or '',
|
||||
'legal_street2': settings.company_legal_street2 or self.env.company.street2 or '',
|
||||
'legal_city': settings.company_legal_city or self.env.company.city or '',
|
||||
'legal_state': settings.company_legal_state_id.name if settings.company_legal_state_id else (self.env.company.state_id.name if self.env.company.state_id else ''),
|
||||
'legal_zip': settings.company_legal_zip or self.env.company.zip or '',
|
||||
'legal_country': settings.company_legal_country_id.name if settings.company_legal_country_id else (self.env.company.country_id.name if self.env.company.country_id else ''),
|
||||
}
|
||||
|
||||
def _get_payroll_contact_info(self):
|
||||
"""Get payroll contact information from settings."""
|
||||
settings = self._get_payroll_settings()
|
||||
return {
|
||||
'name': settings.get_payroll_contact_name(),
|
||||
'phone': settings.payroll_contact_phone or '',
|
||||
'email': settings.payroll_contact_email or '',
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# FORMATTING HELPERS
|
||||
# =========================================================================
|
||||
|
||||
def _format_value(self, value, column_type, options):
|
||||
"""Format value based on column type."""
|
||||
if value is None:
|
||||
return ''
|
||||
|
||||
currency = self.env['res.currency'].browse(options.get('currency_id'))
|
||||
|
||||
if column_type == 'monetary':
|
||||
return formatLang(self.env, value, currency_obj=currency)
|
||||
elif column_type == 'float':
|
||||
return formatLang(self.env, value, digits=2)
|
||||
elif column_type == 'integer':
|
||||
return str(int(value))
|
||||
elif column_type == 'date':
|
||||
if isinstance(value, str):
|
||||
value = fields.Date.from_string(value)
|
||||
return format_date(self.env, value)
|
||||
elif column_type == 'percentage':
|
||||
return f"{float_round(value * 100, 2)}%"
|
||||
else:
|
||||
return str(value) if value else ''
|
||||
|
||||
# =========================================================================
|
||||
# EXPORT METHODS
|
||||
# =========================================================================
|
||||
|
||||
def get_xlsx(self, options):
|
||||
"""
|
||||
Generate Excel export of the report.
|
||||
Returns binary data.
|
||||
"""
|
||||
try:
|
||||
import xlsxwriter
|
||||
except ImportError:
|
||||
raise ImportError("xlsxwriter library is required for Excel export")
|
||||
|
||||
output = io.BytesIO()
|
||||
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
|
||||
sheet = workbook.add_worksheet(self.report_name[:31]) # Max 31 chars
|
||||
|
||||
# Styles
|
||||
header_style = workbook.add_format({
|
||||
'bold': True,
|
||||
'bg_color': '#4a86e8',
|
||||
'font_color': 'white',
|
||||
'border': 1,
|
||||
})
|
||||
money_style = workbook.add_format({'num_format': '$#,##0.00'})
|
||||
date_style = workbook.add_format({'num_format': 'yyyy-mm-dd'})
|
||||
total_style = workbook.add_format({'bold': True, 'top': 2})
|
||||
|
||||
# Get data
|
||||
columns = self._get_columns()
|
||||
lines = self._get_lines(options)
|
||||
|
||||
# Add company info from settings
|
||||
company_info = self._get_company_legal_info()
|
||||
contact_info = self._get_payroll_contact_info()
|
||||
|
||||
# Write report header with company info
|
||||
sheet.write(0, 0, company_info['legal_name'] or self.env.company.name, header_style)
|
||||
if company_info['legal_street']:
|
||||
sheet.write(1, 0, company_info['legal_street'])
|
||||
if company_info['legal_city']:
|
||||
addr_line = f"{company_info['legal_city']}, {company_info['legal_state']} {company_info['legal_zip']}"
|
||||
sheet.write(2, 0, addr_line)
|
||||
if contact_info['name']:
|
||||
sheet.write(3, 0, f"Contact: {contact_info['name']}")
|
||||
|
||||
# Write headers (starting at row 5)
|
||||
header_row = 5
|
||||
for col_idx, col in enumerate(columns):
|
||||
sheet.write(header_row, col_idx, col['name'], header_style)
|
||||
sheet.set_column(col_idx, col_idx, 15)
|
||||
|
||||
# Write data (starting after header row)
|
||||
row = header_row + 1
|
||||
for line in lines:
|
||||
values = line.get('values', {})
|
||||
style = total_style if line.get('level', 0) < 0 else None
|
||||
|
||||
for col_idx, col in enumerate(columns):
|
||||
value = values.get(col['field'], '')
|
||||
cell_style = style
|
||||
|
||||
if col['type'] == 'monetary':
|
||||
cell_style = money_style
|
||||
elif col['type'] == 'date':
|
||||
cell_style = date_style
|
||||
|
||||
if cell_style:
|
||||
sheet.write(row, col_idx, value, cell_style)
|
||||
else:
|
||||
sheet.write(row, col_idx, value)
|
||||
row += 1
|
||||
|
||||
workbook.close()
|
||||
output.seek(0)
|
||||
return output.read()
|
||||
|
||||
def get_pdf(self, options):
|
||||
"""
|
||||
Generate PDF export of the report.
|
||||
Returns binary data.
|
||||
"""
|
||||
report_data = self._get_report_data(options)
|
||||
|
||||
# Get settings data
|
||||
company_info = self._get_company_legal_info()
|
||||
contact_info = self._get_payroll_contact_info()
|
||||
|
||||
# Render QWeb template
|
||||
html = self.env['ir.qweb']._render(
|
||||
'fusion_payroll.payroll_report_pdf_template',
|
||||
{
|
||||
'report': self,
|
||||
'options': options,
|
||||
'columns': report_data['columns'],
|
||||
'lines': report_data['lines'],
|
||||
'company': self.env.company,
|
||||
'company_info': company_info,
|
||||
'contact_info': contact_info,
|
||||
'format_value': self._format_value,
|
||||
}
|
||||
)
|
||||
|
||||
# Convert to PDF using wkhtmltopdf
|
||||
pdf = self.env['ir.actions.report']._run_wkhtmltopdf(
|
||||
[html],
|
||||
landscape=True,
|
||||
specific_paperformat_args={'data-report-margin-top': 10}
|
||||
)
|
||||
|
||||
return pdf
|
||||
|
||||
# =========================================================================
|
||||
# ACTION METHODS
|
||||
# =========================================================================
|
||||
|
||||
def action_open_report(self):
|
||||
"""Open the report in client action."""
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_payroll.payroll_report_action',
|
||||
'name': self.report_name,
|
||||
'context': {
|
||||
'report_model': self._name,
|
||||
},
|
||||
}
|
||||
422
fusion_payroll/models/payroll_report_cost.py
Normal file
422
fusion_payroll/models/payroll_report_cost.py
Normal file
@@ -0,0 +1,422 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Cost Reports
|
||||
============
|
||||
- Total Pay
|
||||
- Total Payroll Cost
|
||||
- Deductions and Contributions
|
||||
- Workers' Compensation
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class PayrollReportTotalPay(models.AbstractModel):
|
||||
"""
|
||||
Total Pay Report
|
||||
Breakdown of pay by type per employee.
|
||||
"""
|
||||
_name = 'payroll.report.total.pay'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Total Pay Report'
|
||||
|
||||
report_name = 'Total Pay'
|
||||
report_code = 'total_pay'
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Regular Pay'), 'field': 'regular_pay', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Stat Holiday Pay'), 'field': 'stat_holiday', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Vacation Pay'), 'field': 'vacation_pay', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Total'), 'field': 'total', 'type': 'monetary', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
domain = self._get_domain(options)
|
||||
domain.append(('state', 'in', ['done', 'paid']))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain)
|
||||
|
||||
# Aggregate by employee
|
||||
emp_data = defaultdict(lambda: {
|
||||
'regular_pay': 0,
|
||||
'stat_holiday': 0,
|
||||
'vacation_pay': 0,
|
||||
'total': 0,
|
||||
})
|
||||
|
||||
for slip in payslips:
|
||||
if not slip.employee_id:
|
||||
continue
|
||||
|
||||
emp_key = slip.employee_id.id
|
||||
emp_data[emp_key]['name'] = slip.employee_id.name or 'Unknown'
|
||||
|
||||
if hasattr(slip, 'line_ids') and slip.line_ids:
|
||||
for line in slip.line_ids:
|
||||
if hasattr(line, 'category_id') and line.category_id and hasattr(line.category_id, 'code'):
|
||||
if line.category_id.code == 'BASIC':
|
||||
emp_data[emp_key]['regular_pay'] += line.total or 0
|
||||
if hasattr(line, 'code') and line.code:
|
||||
if line.code == 'STAT_HOLIDAY':
|
||||
emp_data[emp_key]['stat_holiday'] += line.total or 0
|
||||
elif line.code == 'VACATION':
|
||||
emp_data[emp_key]['vacation_pay'] += line.total or 0
|
||||
|
||||
emp_data[emp_key]['total'] += getattr(slip, 'gross_wage', 0) or 0
|
||||
|
||||
lines = []
|
||||
totals = defaultdict(float)
|
||||
|
||||
for emp_id, data in emp_data.items():
|
||||
for key in ['regular_pay', 'stat_holiday', 'vacation_pay', 'total']:
|
||||
totals[key] += data[key]
|
||||
|
||||
lines.append({
|
||||
'id': f'emp_{emp_id}',
|
||||
'name': data['name'],
|
||||
'values': {
|
||||
'name': data['name'],
|
||||
'regular_pay': data['regular_pay'],
|
||||
'stat_holiday': data['stat_holiday'],
|
||||
'vacation_pay': data['vacation_pay'],
|
||||
'total': data['total'],
|
||||
},
|
||||
'level': 0,
|
||||
})
|
||||
|
||||
# Sort by name
|
||||
lines.sort(key=lambda x: x['name'])
|
||||
|
||||
# Total line
|
||||
if lines:
|
||||
lines.append({
|
||||
'id': 'total',
|
||||
'name': _('Total Pay'),
|
||||
'values': {
|
||||
'name': _('Total Pay'),
|
||||
'regular_pay': totals['regular_pay'],
|
||||
'stat_holiday': totals['stat_holiday'],
|
||||
'vacation_pay': totals['vacation_pay'],
|
||||
'total': totals['total'],
|
||||
},
|
||||
'level': -1,
|
||||
'class': 'o_payroll_report_total fw-bold bg-success text-white',
|
||||
})
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
class PayrollReportTotalCost(models.AbstractModel):
|
||||
"""
|
||||
Total Payroll Cost Report
|
||||
Summary of all payroll costs.
|
||||
"""
|
||||
_name = 'payroll.report.total.cost'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Total Payroll Cost Report'
|
||||
|
||||
report_name = 'Total Payroll Cost'
|
||||
report_code = 'total_cost'
|
||||
filter_employee = False
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Item'), 'field': 'item', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Amount'), 'field': 'amount', 'type': 'monetary', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
domain = [
|
||||
('state', 'in', ['done', 'paid']),
|
||||
('company_id', '=', self.env.company.id),
|
||||
]
|
||||
date_from = options.get('date', {}).get('date_from')
|
||||
date_to = options.get('date', {}).get('date_to')
|
||||
if date_from:
|
||||
domain.append(('date_from', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date_to', '<=', date_to))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain)
|
||||
|
||||
# Calculate totals - safely handle missing fields
|
||||
paycheque_wages = 0
|
||||
non_paycheque = 0 # Reimbursements, etc.
|
||||
reimbursements = 0
|
||||
|
||||
ei_employer = 0
|
||||
cpp_employer = 0
|
||||
cpp2_employer = 0
|
||||
|
||||
for slip in payslips:
|
||||
paycheque_wages += getattr(slip, 'gross_wage', 0) or 0
|
||||
ei_employer += getattr(slip, 'employer_ei', 0) or 0
|
||||
cpp_employer += getattr(slip, 'employer_cpp', 0) or 0
|
||||
cpp2_employer += getattr(slip, 'employer_cpp2', 0) or 0
|
||||
|
||||
total_employer_taxes = ei_employer + cpp_employer + cpp2_employer
|
||||
total_pay = paycheque_wages + non_paycheque + reimbursements
|
||||
total_cost = total_pay + total_employer_taxes
|
||||
|
||||
lines = [
|
||||
# Total Pay Section
|
||||
{
|
||||
'id': 'total_pay_header',
|
||||
'name': _('Total Pay'),
|
||||
'values': {'item': _('Total Pay'), 'amount': ''},
|
||||
'level': -1,
|
||||
'class': 'fw-bold',
|
||||
},
|
||||
{
|
||||
'id': 'paycheque_wages',
|
||||
'name': _('Paycheque Wages'),
|
||||
'values': {'item': _(' Paycheque Wages'), 'amount': paycheque_wages},
|
||||
'level': 0,
|
||||
},
|
||||
{
|
||||
'id': 'non_paycheque',
|
||||
'name': _('Non-paycheque Wages'),
|
||||
'values': {'item': _(' Non-paycheque Wages'), 'amount': non_paycheque},
|
||||
'level': 0,
|
||||
},
|
||||
{
|
||||
'id': 'reimbursements',
|
||||
'name': _('Reimbursements'),
|
||||
'values': {'item': _(' Reimbursements'), 'amount': reimbursements},
|
||||
'level': 0,
|
||||
},
|
||||
{
|
||||
'id': 'subtotal_pay',
|
||||
'name': _('Subtotal'),
|
||||
'values': {'item': _('Subtotal'), 'amount': total_pay},
|
||||
'level': 0,
|
||||
'class': 'fw-bold',
|
||||
},
|
||||
# Company Contributions Section
|
||||
{
|
||||
'id': 'contributions_header',
|
||||
'name': _('Company Contributions'),
|
||||
'values': {'item': _('Company Contributions'), 'amount': ''},
|
||||
'level': -1,
|
||||
'class': 'fw-bold',
|
||||
},
|
||||
{
|
||||
'id': 'subtotal_contributions',
|
||||
'name': _('Subtotal'),
|
||||
'values': {'item': _('Subtotal'), 'amount': 0},
|
||||
'level': 0,
|
||||
'class': 'fw-bold',
|
||||
},
|
||||
# Employer Taxes Section
|
||||
{
|
||||
'id': 'employer_taxes_header',
|
||||
'name': _('Employer Taxes'),
|
||||
'values': {'item': _('Employer Taxes'), 'amount': ''},
|
||||
'level': -1,
|
||||
'class': 'fw-bold',
|
||||
},
|
||||
{
|
||||
'id': 'ei_employer',
|
||||
'name': _('Employment Insurance Employer'),
|
||||
'values': {'item': _(' Employment Insurance Employer'), 'amount': ei_employer},
|
||||
'level': 0,
|
||||
},
|
||||
{
|
||||
'id': 'cpp_employer',
|
||||
'name': _('Canada Pension Plan Employer'),
|
||||
'values': {'item': _(' Canada Pension Plan Employer'), 'amount': cpp_employer},
|
||||
'level': 0,
|
||||
},
|
||||
{
|
||||
'id': 'cpp2_employer',
|
||||
'name': _('Second Canada Pension Plan Employer'),
|
||||
'values': {'item': _(' Second Canada Pension Plan Employer'), 'amount': cpp2_employer},
|
||||
'level': 0,
|
||||
},
|
||||
{
|
||||
'id': 'subtotal_employer',
|
||||
'name': _('Subtotal'),
|
||||
'values': {'item': _('Subtotal'), 'amount': total_employer_taxes},
|
||||
'level': 0,
|
||||
'class': 'fw-bold',
|
||||
},
|
||||
# Grand Total
|
||||
{
|
||||
'id': 'total_cost',
|
||||
'name': _('Total Payroll Cost'),
|
||||
'values': {'item': _('Total Payroll Cost'), 'amount': total_cost},
|
||||
'level': -1,
|
||||
'class': 'o_payroll_report_total fw-bold bg-dark text-white',
|
||||
},
|
||||
]
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
class PayrollReportDeductions(models.AbstractModel):
|
||||
"""
|
||||
Deductions and Contributions Report
|
||||
"""
|
||||
_name = 'payroll.report.deductions'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Deductions and Contributions Report'
|
||||
|
||||
report_name = 'Deductions and Contributions'
|
||||
report_code = 'deductions'
|
||||
filter_employee = False
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Description'), 'field': 'description', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Type'), 'field': 'type', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Employee Deductions'), 'field': 'employee_deductions', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Company Contributions'), 'field': 'company_contributions', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Plan Total'), 'field': 'plan_total', 'type': 'monetary', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
domain = [
|
||||
('state', 'in', ['done', 'paid']),
|
||||
('company_id', '=', self.env.company.id),
|
||||
]
|
||||
date_from = options.get('date', {}).get('date_from')
|
||||
date_to = options.get('date', {}).get('date_to')
|
||||
if date_from:
|
||||
domain.append(('date_from', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date_to', '<=', date_to))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain)
|
||||
|
||||
# Aggregate deductions
|
||||
deduction_data = defaultdict(lambda: {'employee': 0, 'company': 0})
|
||||
|
||||
deduction_codes = {
|
||||
'CPP': {'name': 'Canada Pension Plan', 'type': 'Tax', 'employer_code': 'CPP_ER'},
|
||||
'CPP2': {'name': 'Second Canada Pension Plan', 'type': 'Tax', 'employer_code': 'CPP2_ER'},
|
||||
'EI': {'name': 'Employment Insurance', 'type': 'Tax', 'employer_code': 'EI_ER'},
|
||||
}
|
||||
|
||||
for slip in payslips:
|
||||
if not hasattr(slip, 'line_ids') or not slip.line_ids:
|
||||
continue
|
||||
for line in slip.line_ids:
|
||||
if not hasattr(line, 'code') or not line.code:
|
||||
continue
|
||||
if line.code in deduction_codes:
|
||||
deduction_data[line.code]['employee'] += abs(line.total or 0)
|
||||
elif line.code.endswith('_ER'):
|
||||
base_code = line.code[:-3]
|
||||
if base_code in deduction_codes:
|
||||
deduction_data[base_code]['company'] += abs(line.total or 0)
|
||||
|
||||
lines = []
|
||||
for code, info in deduction_codes.items():
|
||||
data = deduction_data[code]
|
||||
total = data['employee'] + data['company']
|
||||
|
||||
lines.append({
|
||||
'id': f'ded_{code}',
|
||||
'name': info['name'],
|
||||
'values': {
|
||||
'description': info['name'],
|
||||
'type': info['type'],
|
||||
'employee_deductions': data['employee'],
|
||||
'company_contributions': data['company'],
|
||||
'plan_total': total,
|
||||
},
|
||||
'level': 0,
|
||||
})
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
class PayrollReportWorkersComp(models.AbstractModel):
|
||||
"""
|
||||
Workers' Compensation Report
|
||||
"""
|
||||
_name = 'payroll.report.workers.comp'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Workers Compensation Report'
|
||||
|
||||
report_name = "Workers' Compensation"
|
||||
report_code = 'workers_comp'
|
||||
filter_employee = False
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Province'), 'field': 'province', 'type': 'char', 'sortable': True},
|
||||
{'name': _("Workers' Comp Class"), 'field': 'wc_class', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Premium Wage Paid'), 'field': 'premium_wage', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Tips Paid'), 'field': 'tips_paid', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Employee Taxes Paid by Employer'), 'field': 'emp_taxes_employer', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Wages Paid'), 'field': 'wages_paid', 'type': 'monetary', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
domain = [
|
||||
('state', 'in', ['done', 'paid']),
|
||||
('company_id', '=', self.env.company.id),
|
||||
]
|
||||
date_from = options.get('date', {}).get('date_from')
|
||||
date_to = options.get('date', {}).get('date_to')
|
||||
if date_from:
|
||||
domain.append(('date_from', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date_to', '<=', date_to))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain)
|
||||
|
||||
# Group by province
|
||||
province_data = defaultdict(lambda: {'wages': 0, 'premium': 0, 'tips': 0, 'emp_taxes': 0})
|
||||
|
||||
for slip in payslips:
|
||||
if not slip.employee_id:
|
||||
continue
|
||||
|
||||
province = 'ON' # Default, would get from employee address
|
||||
if hasattr(slip.employee_id, 'province_of_employment'):
|
||||
province = getattr(slip.employee_id, 'province_of_employment', None) or 'ON'
|
||||
|
||||
province_data[province]['wages'] += getattr(slip, 'gross_wage', 0) or 0
|
||||
|
||||
lines = []
|
||||
total_wages = 0
|
||||
|
||||
for province, data in province_data.items():
|
||||
total_wages += data['wages']
|
||||
lines.append({
|
||||
'id': f'prov_{province}',
|
||||
'name': province,
|
||||
'values': {
|
||||
'province': province,
|
||||
'wc_class': 'No Name Specified',
|
||||
'premium_wage': data['premium'],
|
||||
'tips_paid': data['tips'],
|
||||
'emp_taxes_employer': data['emp_taxes'],
|
||||
'wages_paid': data['wages'],
|
||||
},
|
||||
'level': 0,
|
||||
})
|
||||
|
||||
# Total
|
||||
if lines:
|
||||
lines.append({
|
||||
'id': 'total',
|
||||
'name': _('Total'),
|
||||
'values': {
|
||||
'province': _('Total'),
|
||||
'wc_class': '',
|
||||
'premium_wage': 0,
|
||||
'tips_paid': 0,
|
||||
'emp_taxes_employer': 0,
|
||||
'wages_paid': total_wages,
|
||||
},
|
||||
'level': -1,
|
||||
'class': 'o_payroll_report_total fw-bold bg-success text-white',
|
||||
})
|
||||
|
||||
return lines
|
||||
178
fusion_payroll/models/payroll_report_employee.py
Normal file
178
fusion_payroll/models/payroll_report_employee.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Employee Reports
|
||||
================
|
||||
- Employee Directory (Payroll Item List)
|
||||
- Time Off Report
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class PayrollReportEmployeeDirectory(models.AbstractModel):
|
||||
"""
|
||||
Employee Directory / Payroll Item List Report
|
||||
Shows employees with their pay rates and status.
|
||||
"""
|
||||
_name = 'payroll.report.employee.directory'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Employee Directory Report'
|
||||
|
||||
report_name = 'Payroll Item List'
|
||||
report_code = 'employee_directory'
|
||||
filter_date_range = False # Not date-dependent
|
||||
filter_employee = False
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Salary'), 'field': 'salary', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Regular Pay'), 'field': 'regular_pay', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Hourly 2'), 'field': 'hourly_2', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Overtime Pay'), 'field': 'overtime_pay', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Double Overtime Pay'), 'field': 'double_overtime', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Stat Holiday Pay'), 'field': 'stat_holiday', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Bonus'), 'field': 'bonus', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Status'), 'field': 'status', 'type': 'char', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
# Get all employees
|
||||
employees = self.env['hr.employee'].search([
|
||||
('company_id', '=', self.env.company.id),
|
||||
], order='name')
|
||||
|
||||
lines = []
|
||||
for emp in employees:
|
||||
# Get pay info directly from employee (Fusion Payroll fields)
|
||||
salary = ''
|
||||
regular_pay = ''
|
||||
|
||||
# Use Fusion Payroll fields if available
|
||||
pay_type = getattr(emp, 'pay_type', None)
|
||||
if pay_type == 'salary':
|
||||
salary_amount = getattr(emp, 'salary_amount', 0) or 0
|
||||
if salary_amount:
|
||||
salary = f"${salary_amount * 12:,.2f}/year"
|
||||
elif pay_type == 'hourly':
|
||||
hourly_rate = getattr(emp, 'hourly_rate', 0) or 0
|
||||
if hourly_rate:
|
||||
regular_pay = f"${hourly_rate:,.2f}/hr"
|
||||
else:
|
||||
# Fallback to hourly_cost if available
|
||||
hourly_cost = getattr(emp, 'hourly_cost', 0) or 0
|
||||
if hourly_cost:
|
||||
regular_pay = f"${hourly_cost:,.2f}/hr"
|
||||
|
||||
status = 'Active'
|
||||
if hasattr(emp, 'employment_status') and 'employment_status' in emp._fields:
|
||||
if hasattr(emp._fields['employment_status'], 'selection'):
|
||||
status = dict(emp._fields['employment_status'].selection).get(
|
||||
emp.employment_status, 'Active'
|
||||
)
|
||||
|
||||
# Calculate salary value for sorting
|
||||
salary_value = 0
|
||||
if pay_type == 'salary':
|
||||
salary_value = (getattr(emp, 'salary_amount', 0) or 0) * 12
|
||||
|
||||
lines.append({
|
||||
'id': f'emp_{emp.id}',
|
||||
'name': emp.name,
|
||||
'values': {
|
||||
'name': emp.name,
|
||||
'salary': salary_value if salary_value else '',
|
||||
'regular_pay': regular_pay,
|
||||
'hourly_2': '',
|
||||
'overtime_pay': '',
|
||||
'double_overtime': '',
|
||||
'stat_holiday': '',
|
||||
'bonus': '',
|
||||
'status': status,
|
||||
},
|
||||
'level': 0,
|
||||
'model': 'hr.employee',
|
||||
'res_id': emp.id,
|
||||
})
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
class PayrollReportTimeOff(models.AbstractModel):
|
||||
"""
|
||||
Time Off Report
|
||||
Shows vacation/leave balances by employee.
|
||||
"""
|
||||
_name = 'payroll.report.time.off'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Time Off Report'
|
||||
|
||||
report_name = 'Time Off'
|
||||
report_code = 'time_off'
|
||||
filter_date_range = False
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Employee'), 'field': 'employee', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Vacation'), 'field': 'vacation', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Balance'), 'field': 'balance', 'type': 'float', 'sortable': True},
|
||||
{'name': _('YTD Used'), 'field': 'ytd_used', 'type': 'float', 'sortable': True},
|
||||
{'name': _('Amount Available'), 'field': 'amount_available', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('YTD Amount Used'), 'field': 'ytd_amount_used', 'type': 'monetary', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
# Get employees with vacation policy
|
||||
domain = [('company_id', '=', self.env.company.id)]
|
||||
if 'employment_status' in self.env['hr.employee']._fields:
|
||||
domain.append(('employment_status', '=', 'active'))
|
||||
employees = self.env['hr.employee'].search(domain, order='name')
|
||||
|
||||
lines = []
|
||||
for emp in employees:
|
||||
# Try to get leave allocation info if hr_holidays is installed
|
||||
balance = 0
|
||||
ytd_used = 0
|
||||
|
||||
try:
|
||||
# Check for vacation allocations
|
||||
allocations = self.env['hr.leave.allocation'].search([
|
||||
('employee_id', '=', emp.id),
|
||||
('state', '=', 'validate'),
|
||||
('holiday_status_id.name', 'ilike', 'vacation'),
|
||||
])
|
||||
balance = sum(allocations.mapped('number_of_days'))
|
||||
|
||||
# Get used days this year
|
||||
year_start = fields.Date.today().replace(month=1, day=1)
|
||||
leaves = self.env['hr.leave'].search([
|
||||
('employee_id', '=', emp.id),
|
||||
('state', '=', 'validate'),
|
||||
('holiday_status_id.name', 'ilike', 'vacation'),
|
||||
('date_from', '>=', year_start),
|
||||
])
|
||||
ytd_used = sum(leaves.mapped('number_of_days'))
|
||||
except:
|
||||
pass # hr_holidays may not be installed or different structure
|
||||
|
||||
# Get vacation pay rate from employee
|
||||
vacation_rate = getattr(emp, 'vacation_pay_rate', 4.0)
|
||||
vacation_policy = f"{vacation_rate}% Paid out each pay period"
|
||||
|
||||
lines.append({
|
||||
'id': f'time_{emp.id}',
|
||||
'name': emp.name,
|
||||
'values': {
|
||||
'employee': emp.name,
|
||||
'vacation': vacation_policy,
|
||||
'balance': balance - ytd_used,
|
||||
'ytd_used': ytd_used,
|
||||
'amount_available': 0, # Would need to calculate
|
||||
'ytd_amount_used': 0,
|
||||
},
|
||||
'level': 0,
|
||||
'model': 'hr.employee',
|
||||
'res_id': emp.id,
|
||||
})
|
||||
|
||||
return lines
|
||||
232
fusion_payroll/models/payroll_report_paycheque.py
Normal file
232
fusion_payroll/models/payroll_report_paycheque.py
Normal file
@@ -0,0 +1,232 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Paycheque Reports
|
||||
=================
|
||||
- Paycheque History
|
||||
- Payroll Details
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class PayrollReportPaychequeHistory(models.AbstractModel):
|
||||
"""
|
||||
Paycheque History Report
|
||||
Shows all paycheques with pay date, employee, amounts, payment method.
|
||||
"""
|
||||
_name = 'payroll.report.paycheque.history'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Paycheque History Report'
|
||||
|
||||
report_name = 'Paycheque History'
|
||||
report_code = 'paycheque_history'
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Pay Date'), 'field': 'pay_date', 'type': 'date', 'sortable': True},
|
||||
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Total Pay'), 'field': 'total_pay', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Net Pay'), 'field': 'net_pay', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Pay Method'), 'field': 'pay_method', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Cheque Number'), 'field': 'cheque_number', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Status'), 'field': 'status', 'type': 'char', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
domain = self._get_domain(options)
|
||||
domain.append(('state', 'in', ['done', 'paid']))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain, order='date_to desc, employee_id')
|
||||
|
||||
lines = []
|
||||
for slip in payslips:
|
||||
if not slip.employee_id:
|
||||
continue
|
||||
|
||||
pay_method = '-'
|
||||
if hasattr(slip, 'paid_by') and slip.paid_by:
|
||||
if 'paid_by' in slip._fields and hasattr(slip._fields['paid_by'], 'selection'):
|
||||
pay_method = dict(slip._fields['paid_by'].selection).get(slip.paid_by, '-')
|
||||
|
||||
status = slip.state
|
||||
if 'state' in slip._fields and hasattr(slip._fields['state'], 'selection'):
|
||||
status = dict(slip._fields['state'].selection).get(slip.state, slip.state)
|
||||
|
||||
lines.append({
|
||||
'id': f'payslip_{slip.id}',
|
||||
'name': slip.employee_id.name or 'Unknown',
|
||||
'values': {
|
||||
'pay_date': slip.date_to or '',
|
||||
'name': slip.employee_id.name or 'Unknown',
|
||||
'total_pay': slip.gross_wage or 0,
|
||||
'net_pay': slip.net_wage or 0,
|
||||
'pay_method': pay_method,
|
||||
'cheque_number': getattr(slip, 'cheque_number', None) or '-',
|
||||
'status': status,
|
||||
},
|
||||
'level': 0,
|
||||
'model': 'hr.payslip',
|
||||
'res_id': slip.id,
|
||||
})
|
||||
|
||||
# Add total
|
||||
if lines:
|
||||
lines.append(self._get_total_line(lines, options))
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
class PayrollReportPayrollDetails(models.AbstractModel):
|
||||
"""
|
||||
Payroll Details Report
|
||||
Detailed breakdown per employee per pay date with Gross, Taxes, Net.
|
||||
"""
|
||||
_name = 'payroll.report.payroll.details'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Payroll Details Report'
|
||||
|
||||
report_name = 'Payroll Details'
|
||||
report_code = 'payroll_details'
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Pay Date'), 'field': 'pay_date', 'type': 'date', 'sortable': True},
|
||||
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Hours'), 'field': 'hours', 'type': 'float', 'sortable': True},
|
||||
{'name': _('Gross Pay'), 'field': 'gross_pay', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Other Pay'), 'field': 'other_pay', 'type': 'monetary', 'sortable': False},
|
||||
{'name': _('Employee Taxes'), 'field': 'employee_taxes', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Net Pay'), 'field': 'net_pay', 'type': 'monetary', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
domain = self._get_domain(options)
|
||||
domain.append(('state', 'in', ['done', 'paid']))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain, order='date_to desc, employee_id')
|
||||
|
||||
lines = []
|
||||
|
||||
# Group by pay date for totals
|
||||
current_date = None
|
||||
date_totals = {'gross_pay': 0, 'employee_taxes': 0, 'net_pay': 0, 'hours': 0}
|
||||
|
||||
# Calculate grand totals
|
||||
grand_totals = {'gross_pay': 0, 'employee_taxes': 0, 'net_pay': 0, 'hours': 0}
|
||||
|
||||
for slip in payslips:
|
||||
if not slip.employee_id:
|
||||
continue
|
||||
|
||||
# Calculate worked hours
|
||||
hours = 0
|
||||
if hasattr(slip, 'worked_days_line_ids') and slip.worked_days_line_ids:
|
||||
hours = sum(slip.worked_days_line_ids.mapped('number_of_hours')) or 0
|
||||
|
||||
# Calculate employee taxes
|
||||
employee_taxes = getattr(slip, 'total_employee_deductions', 0) or 0
|
||||
gross_wage = getattr(slip, 'gross_wage', 0) or 0
|
||||
net_wage = getattr(slip, 'net_wage', 0) or 0
|
||||
|
||||
grand_totals['gross_pay'] += gross_wage
|
||||
grand_totals['employee_taxes'] += employee_taxes
|
||||
grand_totals['net_pay'] += net_wage
|
||||
grand_totals['hours'] += hours
|
||||
|
||||
lines.append({
|
||||
'id': f'detail_{slip.id}',
|
||||
'name': slip.employee_id.name or 'Unknown',
|
||||
'values': {
|
||||
'pay_date': slip.date_to or '',
|
||||
'name': slip.employee_id.name or 'Unknown',
|
||||
'hours': hours,
|
||||
'gross_pay': gross_wage,
|
||||
'other_pay': 0, # Can be calculated from specific line types
|
||||
'employee_taxes': employee_taxes,
|
||||
'net_pay': net_wage,
|
||||
},
|
||||
'level': 0,
|
||||
'model': 'hr.payslip',
|
||||
'res_id': slip.id,
|
||||
'unfoldable': True, # Can expand to show breakdown
|
||||
})
|
||||
|
||||
# Add grand total
|
||||
if lines:
|
||||
lines.append({
|
||||
'id': 'grand_total',
|
||||
'name': _('Total'),
|
||||
'values': {
|
||||
'pay_date': '',
|
||||
'name': _('Total'),
|
||||
'hours': grand_totals['hours'],
|
||||
'gross_pay': grand_totals['gross_pay'],
|
||||
'other_pay': 0,
|
||||
'employee_taxes': grand_totals['employee_taxes'],
|
||||
'net_pay': grand_totals['net_pay'],
|
||||
},
|
||||
'level': -1,
|
||||
'class': 'o_payroll_report_total fw-bold',
|
||||
})
|
||||
|
||||
return lines
|
||||
|
||||
def _get_detail_lines(self, payslip_id, options):
|
||||
"""Get expanded detail lines for a payslip."""
|
||||
try:
|
||||
payslip = self.env['hr.payslip'].browse(payslip_id)
|
||||
if not payslip.exists():
|
||||
return []
|
||||
|
||||
lines = []
|
||||
|
||||
if not hasattr(payslip, 'line_ids') or not payslip.line_ids:
|
||||
return lines
|
||||
|
||||
# Gross breakdown
|
||||
gross_lines = payslip.line_ids.filtered(
|
||||
lambda l: hasattr(l, 'category_id') and l.category_id and
|
||||
hasattr(l.category_id, 'code') and
|
||||
l.category_id.code in ['BASIC', 'ALW', 'GROSS']
|
||||
)
|
||||
for line in gross_lines:
|
||||
lines.append({
|
||||
'id': f'line_{line.id}',
|
||||
'name': line.name or '',
|
||||
'values': {
|
||||
'pay_date': '',
|
||||
'name': f' {line.name or ""}',
|
||||
'hours': line.quantity if hasattr(line, 'quantity') and line.quantity else '',
|
||||
'gross_pay': line.total or 0,
|
||||
'other_pay': '',
|
||||
'employee_taxes': '',
|
||||
'net_pay': '',
|
||||
},
|
||||
'level': 1,
|
||||
'class': 'text-muted',
|
||||
})
|
||||
|
||||
# Tax breakdown
|
||||
tax_lines = payslip.line_ids.filtered(
|
||||
lambda l: hasattr(l, 'code') and l.code in ['CPP', 'CPP2', 'EI', 'FED_TAX', 'PROV_TAX']
|
||||
)
|
||||
for line in tax_lines:
|
||||
lines.append({
|
||||
'id': f'tax_{line.id}',
|
||||
'name': line.name or '',
|
||||
'values': {
|
||||
'pay_date': '',
|
||||
'name': f' {line.name or ""}',
|
||||
'hours': '',
|
||||
'gross_pay': '',
|
||||
'other_pay': '',
|
||||
'employee_taxes': abs(line.total or 0),
|
||||
'net_pay': '',
|
||||
},
|
||||
'level': 1,
|
||||
'class': 'text-muted',
|
||||
})
|
||||
|
||||
return lines
|
||||
except Exception:
|
||||
return []
|
||||
282
fusion_payroll/models/payroll_report_summary.py
Normal file
282
fusion_payroll/models/payroll_report_summary.py
Normal file
@@ -0,0 +1,282 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Summary Reports
|
||||
===============
|
||||
- Payroll Summary
|
||||
- Payroll Summary by Employee
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class PayrollReportSummary(models.AbstractModel):
|
||||
"""
|
||||
Payroll Summary Report
|
||||
Per pay date summary with all payroll components.
|
||||
"""
|
||||
_name = 'payroll.report.summary'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Payroll Summary Report'
|
||||
|
||||
report_name = 'Payroll Summary'
|
||||
report_code = 'payroll_summary'
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Pay Date'), 'field': 'pay_date', 'type': 'date', 'sortable': True},
|
||||
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Hours'), 'field': 'hours', 'type': 'float', 'sortable': True},
|
||||
{'name': _('Gross Pay'), 'field': 'gross_pay', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Pretax Deductions'), 'field': 'pretax_deductions', 'type': 'monetary', 'sortable': False},
|
||||
{'name': _('Other Pay'), 'field': 'other_pay', 'type': 'monetary', 'sortable': False},
|
||||
{'name': _('Employee Taxes'), 'field': 'employee_taxes', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Aftertax Deductions'), 'field': 'aftertax_deductions', 'type': 'monetary', 'sortable': False},
|
||||
{'name': _('Net Pay'), 'field': 'net_pay', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Employer Taxes'), 'field': 'employer_taxes', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Company Contributions'), 'field': 'company_contributions', 'type': 'monetary', 'sortable': False},
|
||||
{'name': _('Total Payroll Cost'), 'field': 'total_cost', 'type': 'monetary', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
domain = self._get_domain(options)
|
||||
domain.append(('state', 'in', ['done', 'paid']))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain, order='date_to desc, employee_id')
|
||||
|
||||
lines = []
|
||||
totals = defaultdict(float)
|
||||
|
||||
for slip in payslips:
|
||||
if not slip.employee_id:
|
||||
continue
|
||||
|
||||
hours = 0
|
||||
if hasattr(slip, 'worked_days_line_ids') and slip.worked_days_line_ids:
|
||||
hours = sum(slip.worked_days_line_ids.mapped('number_of_hours')) or 0
|
||||
|
||||
employee_taxes = getattr(slip, 'total_employee_deductions', 0) or 0
|
||||
employer_taxes = getattr(slip, 'total_employer_cost', 0) or 0
|
||||
gross_wage = getattr(slip, 'gross_wage', 0) or 0
|
||||
net_wage = getattr(slip, 'net_wage', 0) or 0
|
||||
total_cost = gross_wage + employer_taxes
|
||||
|
||||
values = {
|
||||
'pay_date': slip.date_to or '',
|
||||
'name': slip.employee_id.name or 'Unknown',
|
||||
'hours': hours,
|
||||
'gross_pay': gross_wage,
|
||||
'pretax_deductions': 0,
|
||||
'other_pay': 0,
|
||||
'employee_taxes': employee_taxes,
|
||||
'aftertax_deductions': 0,
|
||||
'net_pay': net_wage,
|
||||
'employer_taxes': employer_taxes,
|
||||
'company_contributions': 0,
|
||||
'total_cost': total_cost,
|
||||
}
|
||||
|
||||
# Accumulate totals
|
||||
for key in ['hours', 'gross_pay', 'employee_taxes', 'net_pay', 'employer_taxes', 'total_cost']:
|
||||
totals[key] += values[key]
|
||||
|
||||
lines.append({
|
||||
'id': f'summary_{slip.id}',
|
||||
'name': slip.employee_id.name or 'Unknown',
|
||||
'values': values,
|
||||
'level': 0,
|
||||
'model': 'hr.payslip',
|
||||
'res_id': slip.id,
|
||||
})
|
||||
|
||||
# Total line
|
||||
if lines:
|
||||
lines.insert(0, {
|
||||
'id': 'total',
|
||||
'name': _('Total'),
|
||||
'values': {
|
||||
'pay_date': '',
|
||||
'name': _('Total'),
|
||||
'hours': totals['hours'],
|
||||
'gross_pay': totals['gross_pay'],
|
||||
'pretax_deductions': 0,
|
||||
'other_pay': 0,
|
||||
'employee_taxes': totals['employee_taxes'],
|
||||
'aftertax_deductions': 0,
|
||||
'net_pay': totals['net_pay'],
|
||||
'employer_taxes': totals['employer_taxes'],
|
||||
'company_contributions': 0,
|
||||
'total_cost': totals['total_cost'],
|
||||
},
|
||||
'level': -1,
|
||||
'class': 'o_payroll_report_total fw-bold bg-light',
|
||||
})
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
class PayrollReportSummaryByEmployee(models.AbstractModel):
|
||||
"""
|
||||
Payroll Summary by Employee Report
|
||||
Pivot-style with employees as columns, pay types as rows.
|
||||
"""
|
||||
_name = 'payroll.report.summary.by.employee'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Payroll Summary by Employee Report'
|
||||
|
||||
report_name = 'Payroll Summary by Employee'
|
||||
report_code = 'payroll_summary_employee'
|
||||
|
||||
def _get_columns(self):
|
||||
# Dynamic columns based on employees in date range
|
||||
# Base columns first
|
||||
return [
|
||||
{'name': _('Payroll'), 'field': 'payroll_item', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Total'), 'field': 'total', 'type': 'monetary', 'sortable': True},
|
||||
# Employee columns will be added dynamically
|
||||
]
|
||||
|
||||
def _get_dynamic_columns(self, options):
|
||||
"""Get columns including employee names."""
|
||||
date_from = options.get('date', {}).get('date_from')
|
||||
date_to = options.get('date', {}).get('date_to')
|
||||
|
||||
domain = [
|
||||
('state', 'in', ['done', 'paid']),
|
||||
('company_id', '=', self.env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date_from', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date_to', '<=', date_to))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain)
|
||||
employees = payslips.mapped('employee_id')
|
||||
|
||||
columns = [
|
||||
{'name': _('Payroll'), 'field': 'payroll_item', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Total'), 'field': 'total', 'type': 'monetary', 'sortable': True},
|
||||
]
|
||||
|
||||
for emp in employees.sorted('name'):
|
||||
columns.append({
|
||||
'name': emp.name,
|
||||
'field': f'emp_{emp.id}',
|
||||
'type': 'monetary',
|
||||
'sortable': True,
|
||||
})
|
||||
|
||||
return columns, employees
|
||||
|
||||
def _get_lines(self, options):
|
||||
date_from = options.get('date', {}).get('date_from')
|
||||
date_to = options.get('date', {}).get('date_to')
|
||||
|
||||
domain = [
|
||||
('state', 'in', ['done', 'paid']),
|
||||
('company_id', '=', self.env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date_from', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date_to', '<=', date_to))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain)
|
||||
employees = payslips.mapped('employee_id').sorted('name')
|
||||
|
||||
# Initialize data structure
|
||||
rows = {
|
||||
'hours': {'name': _('Hours'), 'is_header': True, 'totals': defaultdict(float)},
|
||||
'regular_pay_hrs': {'name': _('Regular Pay'), 'parent': 'hours', 'totals': defaultdict(float)},
|
||||
'stat_holiday_hrs': {'name': _('Stat Holiday Pay'), 'parent': 'hours', 'totals': defaultdict(float)},
|
||||
'gross_pay': {'name': _('Gross Pay'), 'is_header': True, 'totals': defaultdict(float)},
|
||||
'regular_pay': {'name': _('Regular Pay'), 'parent': 'gross_pay', 'totals': defaultdict(float)},
|
||||
'stat_holiday_pay': {'name': _('Stat Holiday Pay'), 'parent': 'gross_pay', 'totals': defaultdict(float)},
|
||||
'vacation_pay': {'name': _('Vacation Pay'), 'parent': 'gross_pay', 'totals': defaultdict(float)},
|
||||
'adjusted_gross': {'name': _('Adjusted Gross'), 'is_subtotal': True, 'totals': defaultdict(float)},
|
||||
'employee_taxes': {'name': _('Employee Taxes & Deductions'), 'is_header': True, 'totals': defaultdict(float)},
|
||||
'income_tax': {'name': _('Income Tax'), 'parent': 'employee_taxes', 'totals': defaultdict(float)},
|
||||
'ei': {'name': _('Employment Insurance'), 'parent': 'employee_taxes', 'totals': defaultdict(float)},
|
||||
'cpp': {'name': _('Canada Pension Plan'), 'parent': 'employee_taxes', 'totals': defaultdict(float)},
|
||||
'net_pay': {'name': _('Net Pay'), 'is_header': True, 'totals': defaultdict(float)},
|
||||
'employer_taxes': {'name': _('Employer Taxes & Contributions'), 'is_header': True, 'totals': defaultdict(float)},
|
||||
'ei_employer': {'name': _('Employment Insurance Employer'), 'parent': 'employer_taxes', 'totals': defaultdict(float)},
|
||||
'cpp_employer': {'name': _('Canada Pension Plan Employer'), 'parent': 'employer_taxes', 'totals': defaultdict(float)},
|
||||
'total_cost': {'name': _('Total Payroll Cost'), 'is_total': True, 'totals': defaultdict(float)},
|
||||
}
|
||||
|
||||
# Aggregate data by employee
|
||||
for slip in payslips:
|
||||
if not slip.employee_id:
|
||||
continue
|
||||
|
||||
emp_key = f'emp_{slip.employee_id.id}'
|
||||
|
||||
# Hours
|
||||
hours = 0
|
||||
if hasattr(slip, 'worked_days_line_ids') and slip.worked_days_line_ids:
|
||||
hours = sum(slip.worked_days_line_ids.mapped('number_of_hours')) or 0
|
||||
rows['hours']['totals'][emp_key] += hours
|
||||
rows['regular_pay_hrs']['totals'][emp_key] += hours
|
||||
|
||||
# Gross
|
||||
gross_wage = getattr(slip, 'gross_wage', 0) or 0
|
||||
rows['gross_pay']['totals'][emp_key] += gross_wage
|
||||
rows['regular_pay']['totals'][emp_key] += gross_wage
|
||||
rows['adjusted_gross']['totals'][emp_key] += gross_wage
|
||||
|
||||
# Employee taxes
|
||||
rows['employee_taxes']['totals'][emp_key] += getattr(slip, 'total_employee_deductions', 0) or 0
|
||||
rows['income_tax']['totals'][emp_key] += getattr(slip, 'employee_income_tax', 0) or 0
|
||||
rows['ei']['totals'][emp_key] += getattr(slip, 'employee_ei', 0) or 0
|
||||
rows['cpp']['totals'][emp_key] += getattr(slip, 'employee_cpp', 0) or 0
|
||||
|
||||
# Net
|
||||
rows['net_pay']['totals'][emp_key] += getattr(slip, 'net_wage', 0) or 0
|
||||
|
||||
# Employer
|
||||
total_employer_cost = getattr(slip, 'total_employer_cost', 0) or 0
|
||||
rows['employer_taxes']['totals'][emp_key] += total_employer_cost
|
||||
rows['ei_employer']['totals'][emp_key] += getattr(slip, 'employer_ei', 0) or 0
|
||||
rows['cpp_employer']['totals'][emp_key] += getattr(slip, 'employer_cpp', 0) or 0
|
||||
|
||||
# Total cost
|
||||
rows['total_cost']['totals'][emp_key] += gross_wage + total_employer_cost
|
||||
|
||||
# Build lines
|
||||
lines = []
|
||||
for row_key, row_data in rows.items():
|
||||
values = {'payroll_item': row_data['name']}
|
||||
|
||||
# Calculate total
|
||||
total = sum(row_data['totals'].values())
|
||||
values['total'] = total
|
||||
|
||||
# Add employee columns
|
||||
for emp in employees:
|
||||
emp_field = f'emp_{emp.id}'
|
||||
values[emp_field] = row_data['totals'].get(emp_field, 0)
|
||||
|
||||
level = 0
|
||||
css_class = ''
|
||||
if row_data.get('is_header'):
|
||||
level = -1
|
||||
css_class = 'fw-bold'
|
||||
elif row_data.get('parent'):
|
||||
level = 1
|
||||
values['payroll_item'] = f" {row_data['name']}"
|
||||
elif row_data.get('is_subtotal'):
|
||||
css_class = 'fw-bold'
|
||||
elif row_data.get('is_total'):
|
||||
level = -1
|
||||
css_class = 'fw-bold bg-primary text-white'
|
||||
|
||||
lines.append({
|
||||
'id': row_key,
|
||||
'name': row_data['name'],
|
||||
'values': values,
|
||||
'level': level,
|
||||
'class': css_class,
|
||||
})
|
||||
|
||||
return lines
|
||||
350
fusion_payroll/models/payroll_report_tax.py
Normal file
350
fusion_payroll/models/payroll_report_tax.py
Normal file
@@ -0,0 +1,350 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tax Reports
|
||||
===========
|
||||
- Payroll Tax Liability
|
||||
- Payroll Tax Payments
|
||||
- Payroll Tax and Wage Summary
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class PayrollReportTaxLiability(models.AbstractModel):
|
||||
"""
|
||||
Payroll Tax Liability Report
|
||||
Shows tax amounts owed vs paid.
|
||||
"""
|
||||
_name = 'payroll.report.tax.liability'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Payroll Tax Liability Report'
|
||||
|
||||
report_name = 'Payroll Tax Liability'
|
||||
report_code = 'tax_liability'
|
||||
filter_employee = False
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Tax Type'), 'field': 'tax_type', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Tax Amount'), 'field': 'tax_amount', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Tax Paid'), 'field': 'tax_paid', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Tax Owed'), 'field': 'tax_owed', 'type': 'monetary', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
date_from = options.get('date', {}).get('date_from')
|
||||
date_to = options.get('date', {}).get('date_to')
|
||||
|
||||
# Build domain for payslips
|
||||
domain = [
|
||||
('state', 'in', ['done', 'paid']),
|
||||
('company_id', '=', self.env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date_from', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date_to', '<=', date_to))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain)
|
||||
|
||||
# Calculate totals by tax type
|
||||
tax_totals = {
|
||||
'income_tax': {'name': _('Income Tax'), 'amount': 0, 'codes': ['FED_TAX', 'PROV_TAX']},
|
||||
'ei_employee': {'name': _('Employment Insurance'), 'amount': 0, 'codes': ['EI']},
|
||||
'ei_employer': {'name': _('Employment Insurance Employer'), 'amount': 0, 'codes': ['EI_ER']},
|
||||
'cpp_employee': {'name': _('Canada Pension Plan'), 'amount': 0, 'codes': ['CPP']},
|
||||
'cpp_employer': {'name': _('Canada Pension Plan Employer'), 'amount': 0, 'codes': ['CPP_ER']},
|
||||
'cpp2_employee': {'name': _('Second Canada Pension Plan'), 'amount': 0, 'codes': ['CPP2']},
|
||||
'cpp2_employer': {'name': _('Second Canada Pension Plan Employer'), 'amount': 0, 'codes': ['CPP2_ER']},
|
||||
}
|
||||
|
||||
for slip in payslips:
|
||||
if not hasattr(slip, 'line_ids') or not slip.line_ids:
|
||||
continue
|
||||
for line in slip.line_ids:
|
||||
if not hasattr(line, 'code') or not line.code:
|
||||
continue
|
||||
for key, data in tax_totals.items():
|
||||
if line.code in data['codes']:
|
||||
tax_totals[key]['amount'] += abs(line.total or 0)
|
||||
|
||||
# Get paid amounts from remittances
|
||||
remittance_domain = [
|
||||
('state', '=', 'paid'),
|
||||
('company_id', '=', self.env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
remittance_domain.append(('period_start', '>=', date_from))
|
||||
if date_to:
|
||||
remittance_domain.append(('period_end', '<=', date_to))
|
||||
|
||||
remittances = self.env['hr.tax.remittance'].search(remittance_domain)
|
||||
|
||||
# Safely get remittance fields
|
||||
def safe_sum(records, field_name):
|
||||
if not records:
|
||||
return 0
|
||||
try:
|
||||
return sum(records.mapped(field_name)) or 0
|
||||
except:
|
||||
return 0
|
||||
|
||||
paid_totals = {
|
||||
'income_tax': safe_sum(remittances, 'income_tax'),
|
||||
'ei_employee': safe_sum(remittances, 'ei_employee'),
|
||||
'ei_employer': safe_sum(remittances, 'ei_employer'),
|
||||
'cpp_employee': safe_sum(remittances, 'cpp_employee'),
|
||||
'cpp2_employee': safe_sum(remittances, 'cpp2_employee'),
|
||||
'cpp_employer': safe_sum(remittances, 'cpp_employer'),
|
||||
'cpp2_employer': safe_sum(remittances, 'cpp2_employer'),
|
||||
}
|
||||
|
||||
lines = []
|
||||
grand_total = {'amount': 0, 'paid': 0, 'owed': 0}
|
||||
|
||||
# Federal Taxes header
|
||||
lines.append({
|
||||
'id': 'federal_header',
|
||||
'name': _('Federal Taxes'),
|
||||
'values': {
|
||||
'tax_type': _('Federal Taxes'),
|
||||
'tax_amount': '',
|
||||
'tax_paid': '',
|
||||
'tax_owed': '',
|
||||
},
|
||||
'level': -1,
|
||||
'class': 'fw-bold',
|
||||
})
|
||||
|
||||
for key, data in tax_totals.items():
|
||||
paid = paid_totals.get(key, 0)
|
||||
owed = data['amount'] - paid
|
||||
|
||||
grand_total['amount'] += data['amount']
|
||||
grand_total['paid'] += paid
|
||||
grand_total['owed'] += owed
|
||||
|
||||
lines.append({
|
||||
'id': f'tax_{key}',
|
||||
'name': data['name'],
|
||||
'values': {
|
||||
'tax_type': f" {data['name']}",
|
||||
'tax_amount': data['amount'],
|
||||
'tax_paid': paid,
|
||||
'tax_owed': owed,
|
||||
},
|
||||
'level': 0,
|
||||
})
|
||||
|
||||
# Grand total
|
||||
lines.append({
|
||||
'id': 'grand_total',
|
||||
'name': _('Total'),
|
||||
'values': {
|
||||
'tax_type': _('Total'),
|
||||
'tax_amount': grand_total['amount'],
|
||||
'tax_paid': grand_total['paid'],
|
||||
'tax_owed': grand_total['owed'],
|
||||
},
|
||||
'level': -1,
|
||||
'class': 'o_payroll_report_total fw-bold',
|
||||
})
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
class PayrollReportTaxPayments(models.AbstractModel):
|
||||
"""
|
||||
Payroll Tax Payments Report
|
||||
Shows history of tax remittance payments.
|
||||
"""
|
||||
_name = 'payroll.report.tax.payments'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Payroll Tax Payments Report'
|
||||
|
||||
report_name = 'Payroll Tax Payments'
|
||||
report_code = 'tax_payments'
|
||||
filter_employee = False
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Payment Date'), 'field': 'payment_date', 'type': 'date', 'sortable': True},
|
||||
{'name': _('Tax Type'), 'field': 'tax_type', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Amount'), 'field': 'amount', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Payment Method'), 'field': 'payment_method', 'type': 'char', 'sortable': False},
|
||||
{'name': _('Notes'), 'field': 'notes', 'type': 'char', 'sortable': False},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
date_from = options.get('date', {}).get('date_from')
|
||||
date_to = options.get('date', {}).get('date_to')
|
||||
|
||||
domain = [
|
||||
('state', '=', 'paid'),
|
||||
('company_id', '=', self.env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('payment_date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('payment_date', '<=', date_to))
|
||||
|
||||
remittances = self.env['hr.tax.remittance'].search(domain, order='payment_date desc')
|
||||
|
||||
lines = []
|
||||
for rem in remittances:
|
||||
period_start = getattr(rem, 'period_start', '') or ''
|
||||
period_end = getattr(rem, 'period_end', '') or ''
|
||||
period_str = f"{period_start} - {period_end}" if period_start and period_end else ''
|
||||
|
||||
lines.append({
|
||||
'id': f'remit_{rem.id}',
|
||||
'name': rem.name or 'Unknown',
|
||||
'values': {
|
||||
'payment_date': getattr(rem, 'payment_date', '') or '',
|
||||
'tax_type': f"Federal Taxes\n{period_str}" if period_str else 'Federal Taxes',
|
||||
'amount': getattr(rem, 'total', 0) or 0,
|
||||
'payment_method': getattr(rem, 'payment_method', None) or 'Manual',
|
||||
'notes': getattr(rem, 'payment_reference', None) or '',
|
||||
},
|
||||
'level': 0,
|
||||
'model': 'hr.tax.remittance',
|
||||
'res_id': rem.id,
|
||||
})
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
class PayrollReportTaxWageSummary(models.AbstractModel):
|
||||
"""
|
||||
Payroll Tax and Wage Summary Report
|
||||
Shows total wages, excess wages, taxable wages, and tax amounts.
|
||||
"""
|
||||
_name = 'payroll.report.tax.wage.summary'
|
||||
_inherit = 'payroll.report'
|
||||
_description = 'Payroll Tax and Wage Summary Report'
|
||||
|
||||
report_name = 'Payroll Tax and Wage Summary'
|
||||
report_code = 'tax_wage_summary'
|
||||
filter_employee = False
|
||||
|
||||
def _get_columns(self):
|
||||
return [
|
||||
{'name': _('Tax Type'), 'field': 'tax_type', 'type': 'char', 'sortable': True},
|
||||
{'name': _('Total Wages'), 'field': 'total_wages', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Excess Wages'), 'field': 'excess_wages', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Taxable Wages'), 'field': 'taxable_wages', 'type': 'monetary', 'sortable': True},
|
||||
{'name': _('Tax Amount'), 'field': 'tax_amount', 'type': 'monetary', 'sortable': True},
|
||||
]
|
||||
|
||||
def _get_lines(self, options):
|
||||
date_from = options.get('date', {}).get('date_from')
|
||||
date_to = options.get('date', {}).get('date_to')
|
||||
|
||||
domain = [
|
||||
('state', 'in', ['done', 'paid']),
|
||||
('company_id', '=', self.env.company.id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date_from', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date_to', '<=', date_to))
|
||||
|
||||
payslips = self.env['hr.payslip'].search(domain)
|
||||
|
||||
# Calculate totals - safely handle missing gross_wage field
|
||||
total_wages = 0
|
||||
for slip in payslips:
|
||||
total_wages += getattr(slip, 'gross_wage', 0) or 0
|
||||
|
||||
# Tax calculations
|
||||
tax_data = [
|
||||
{
|
||||
'name': _('Income Tax'),
|
||||
'codes': ['FED_TAX', 'PROV_TAX'],
|
||||
'total_wages': total_wages,
|
||||
'excess_wages': 0, # No excess for income tax
|
||||
},
|
||||
{
|
||||
'name': _('Employment Insurance'),
|
||||
'codes': ['EI'],
|
||||
'total_wages': total_wages,
|
||||
'excess_wages': 0, # Would need to calculate based on max
|
||||
},
|
||||
{
|
||||
'name': _('Employment Insurance Employer'),
|
||||
'codes': ['EI_ER'],
|
||||
'total_wages': total_wages,
|
||||
'excess_wages': 0,
|
||||
},
|
||||
{
|
||||
'name': _('Canada Pension Plan'),
|
||||
'codes': ['CPP'],
|
||||
'total_wages': total_wages,
|
||||
'excess_wages': 0,
|
||||
},
|
||||
{
|
||||
'name': _('Canada Pension Plan Employer'),
|
||||
'codes': ['CPP_ER'],
|
||||
'total_wages': total_wages,
|
||||
'excess_wages': 0,
|
||||
},
|
||||
{
|
||||
'name': _('Second Canada Pension Plan'),
|
||||
'codes': ['CPP2'],
|
||||
'total_wages': total_wages,
|
||||
'excess_wages': 0,
|
||||
},
|
||||
{
|
||||
'name': _('Second Canada Pension Plan Employer'),
|
||||
'codes': ['CPP2_ER'],
|
||||
'total_wages': total_wages,
|
||||
'excess_wages': 0,
|
||||
},
|
||||
]
|
||||
|
||||
lines = []
|
||||
|
||||
# Federal header
|
||||
lines.append({
|
||||
'id': 'federal_header',
|
||||
'name': _('Federal Taxes'),
|
||||
'values': {
|
||||
'tax_type': _('Federal Taxes'),
|
||||
'total_wages': '',
|
||||
'excess_wages': '',
|
||||
'taxable_wages': '',
|
||||
'tax_amount': '',
|
||||
},
|
||||
'level': -1,
|
||||
'class': 'fw-bold',
|
||||
})
|
||||
|
||||
grand_tax = 0
|
||||
for data in tax_data:
|
||||
tax_amount = 0
|
||||
for slip in payslips:
|
||||
if not hasattr(slip, 'line_ids') or not slip.line_ids:
|
||||
continue
|
||||
for line in slip.line_ids:
|
||||
if not hasattr(line, 'code') or not line.code:
|
||||
continue
|
||||
if line.code in data['codes']:
|
||||
tax_amount += abs(line.total or 0)
|
||||
|
||||
taxable = data['total_wages'] - data['excess_wages']
|
||||
grand_tax += tax_amount
|
||||
|
||||
lines.append({
|
||||
'id': f"tax_{data['name']}",
|
||||
'name': data['name'],
|
||||
'values': {
|
||||
'tax_type': f" {data['name']}",
|
||||
'total_wages': data['total_wages'],
|
||||
'excess_wages': data['excess_wages'],
|
||||
'taxable_wages': taxable,
|
||||
'tax_amount': tax_amount,
|
||||
},
|
||||
'level': 0,
|
||||
})
|
||||
|
||||
return lines
|
||||
131
fusion_payroll/models/payroll_tax_payment_schedule.py
Normal file
131
fusion_payroll/models/payroll_tax_payment_schedule.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
from datetime import date
|
||||
|
||||
|
||||
class PayrollTaxPaymentSchedule(models.Model):
|
||||
"""
|
||||
Tax Payment Schedule
|
||||
Date-effective payment schedules for provincial taxes.
|
||||
"""
|
||||
_name = 'payroll.tax.payment.schedule'
|
||||
_description = 'Tax Payment Schedule'
|
||||
_order = 'province, effective_date desc'
|
||||
|
||||
config_id = fields.Many2one(
|
||||
'payroll.config.settings',
|
||||
string='Payroll Settings',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
related='config_id.company_id',
|
||||
string='Company',
|
||||
store=True,
|
||||
)
|
||||
province = fields.Selection([
|
||||
('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'),
|
||||
], string='Province', required=True)
|
||||
payment_frequency = fields.Selection([
|
||||
('monthly', 'Monthly'),
|
||||
('quarterly', 'Quarterly'),
|
||||
('annually', 'Annually'),
|
||||
], string='Payment Frequency', required=True, default='quarterly')
|
||||
effective_date = fields.Date(
|
||||
string='Effective Date',
|
||||
required=True,
|
||||
help='Date when this payment schedule becomes effective',
|
||||
)
|
||||
form_type = fields.Char(
|
||||
string='Form Type',
|
||||
help='Tax form type (e.g., Form PD7A)',
|
||||
)
|
||||
is_current = fields.Boolean(
|
||||
string='Current Schedule',
|
||||
compute='_compute_is_current',
|
||||
store=True,
|
||||
help='True if this is the currently active schedule',
|
||||
)
|
||||
display_name = fields.Char(
|
||||
string='Display Name',
|
||||
compute='_compute_display_name',
|
||||
)
|
||||
|
||||
@api.depends('payment_frequency', 'effective_date', 'is_current')
|
||||
def _compute_display_name(self):
|
||||
"""Compute display name for the schedule."""
|
||||
for schedule in self:
|
||||
freq_map = {
|
||||
'monthly': 'Monthly',
|
||||
'quarterly': 'Quarterly',
|
||||
'annually': 'Annually',
|
||||
}
|
||||
freq = freq_map.get(schedule.payment_frequency, schedule.payment_frequency)
|
||||
date_str = schedule.effective_date.strftime('%m/%d/%Y') if schedule.effective_date else ''
|
||||
current = ' (current schedule)' if schedule.is_current else ''
|
||||
schedule.display_name = f"{freq} since {date_str}{current}"
|
||||
|
||||
@api.depends('effective_date', 'province', 'config_id.provincial_tax_schedule_ids')
|
||||
def _compute_is_current(self):
|
||||
"""Determine if this is the current active schedule."""
|
||||
today = date.today()
|
||||
for schedule in self:
|
||||
# Get all schedules for this province, ordered by effective_date desc
|
||||
all_schedules = self.search([
|
||||
('config_id', '=', schedule.config_id.id),
|
||||
('province', '=', schedule.province),
|
||||
], order='effective_date desc')
|
||||
|
||||
# The current schedule is the one with the most recent effective_date <= today
|
||||
current_schedule = None
|
||||
for sched in all_schedules:
|
||||
if sched.effective_date <= today:
|
||||
current_schedule = sched
|
||||
break
|
||||
|
||||
schedule.is_current = (current_schedule and current_schedule.id == schedule.id)
|
||||
|
||||
@api.model
|
||||
def get_current_schedule(self, config_id, province, check_date=None):
|
||||
"""Get the current active schedule for a province."""
|
||||
if not check_date:
|
||||
check_date = date.today()
|
||||
|
||||
schedule = self.search([
|
||||
('config_id', '=', config_id),
|
||||
('province', '=', province),
|
||||
('effective_date', '<=', check_date),
|
||||
], order='effective_date desc', limit=1)
|
||||
|
||||
return schedule
|
||||
|
||||
@api.constrains('effective_date', 'province', 'config_id')
|
||||
def _check_overlapping_schedules(self):
|
||||
"""Warn if schedules overlap (but allow for historical changes)."""
|
||||
for schedule in self:
|
||||
# Allow multiple schedules, but warn if dates are very close
|
||||
overlapping = self.search([
|
||||
('config_id', '=', schedule.config_id.id),
|
||||
('province', '=', schedule.province),
|
||||
('id', '!=', schedule.id),
|
||||
('effective_date', '=', schedule.effective_date),
|
||||
])
|
||||
if overlapping:
|
||||
raise UserError(_(
|
||||
'A payment schedule for %s already exists with effective date %s. '
|
||||
'Please use a different date.'
|
||||
) % (schedule.province, schedule.effective_date.strftime('%m/%d/%Y')))
|
||||
103
fusion_payroll/models/payroll_work_location.py
Normal file
103
fusion_payroll/models/payroll_work_location.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class PayrollWorkLocation(models.Model):
|
||||
"""
|
||||
Work Location
|
||||
Represents a physical work location where employees work.
|
||||
"""
|
||||
_name = 'payroll.work.location'
|
||||
_description = 'Work Location'
|
||||
_order = 'is_primary desc, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Location Name',
|
||||
help='Name or identifier for this work location',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
ondelete='cascade',
|
||||
)
|
||||
street = fields.Char(
|
||||
string='Street Address',
|
||||
)
|
||||
street2 = fields.Char(
|
||||
string='Street Address 2',
|
||||
)
|
||||
city = fields.Char(
|
||||
string='City',
|
||||
)
|
||||
state_id = fields.Many2one(
|
||||
'res.country.state',
|
||||
string='Province',
|
||||
domain="[('country_id', '=?', country_id)]",
|
||||
)
|
||||
zip = fields.Char(
|
||||
string='Postal Code',
|
||||
)
|
||||
country_id = fields.Many2one(
|
||||
'res.country',
|
||||
string='Country',
|
||||
default=lambda self: self.env.ref('base.ca', raise_if_not_found=False),
|
||||
)
|
||||
is_primary = fields.Boolean(
|
||||
string='Primary Location',
|
||||
default=False,
|
||||
help='Mark this as the primary work location',
|
||||
)
|
||||
status = fields.Selection([
|
||||
('active', 'Active'),
|
||||
('inactive', 'Inactive'),
|
||||
], string='Status', default='active', required=True)
|
||||
|
||||
employee_ids = fields.Many2many(
|
||||
'hr.employee',
|
||||
'payroll_work_location_employee_rel',
|
||||
'location_id',
|
||||
'employee_id',
|
||||
string='Employees',
|
||||
help='Employees assigned to this work location',
|
||||
)
|
||||
employee_count = fields.Integer(
|
||||
string='Employees Assigned',
|
||||
compute='_compute_employee_count',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('employee_ids')
|
||||
def _compute_employee_count(self):
|
||||
"""Compute number of employees assigned to this location."""
|
||||
for location in self:
|
||||
location.employee_count = len(location.employee_ids)
|
||||
|
||||
@api.constrains('is_primary')
|
||||
def _check_primary_location(self):
|
||||
"""Ensure only one primary location per company."""
|
||||
for location in self:
|
||||
if location.is_primary:
|
||||
other_primary = self.search([
|
||||
('company_id', '=', location.company_id.id),
|
||||
('is_primary', '=', True),
|
||||
('id', '!=', location.id),
|
||||
])
|
||||
if other_primary:
|
||||
raise UserError(_('Only one primary location is allowed per company.'))
|
||||
|
||||
def name_get(self):
|
||||
"""Return display name with address."""
|
||||
result = []
|
||||
for location in self:
|
||||
name = location.name or _('Unnamed Location')
|
||||
if location.city:
|
||||
name = f"{name}, {location.city}"
|
||||
if location.state_id:
|
||||
name = f"{name}, {location.state_id.code}"
|
||||
if location.is_primary:
|
||||
name = f"{name} ({_('PRIMARY')})"
|
||||
result.append((location.id, name))
|
||||
return result
|
||||
97
fusion_payroll/models/pdf_field_position.py
Normal file
97
fusion_payroll/models/pdf_field_position.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class PdfFieldPosition(models.Model):
|
||||
"""PDF Field Position Configuration
|
||||
|
||||
Stores user-configurable positions for text overlay on flattened PDF templates.
|
||||
Each record defines where a specific field should be placed on a PDF template.
|
||||
"""
|
||||
_name = 'pdf.field.position'
|
||||
_description = 'PDF Field Position'
|
||||
_order = 'template_type, sequence, field_name'
|
||||
_rec_name = 'field_name'
|
||||
|
||||
template_type = fields.Selection([
|
||||
('T4', 'T4 Slip'),
|
||||
('T4 Summary', 'T4 Summary'),
|
||||
('T4A', 'T4A Slip'),
|
||||
('T4A Summary', 'T4A Summary'),
|
||||
], string='Template Type', required=True, index=True,
|
||||
help='The PDF template type this field position applies to')
|
||||
|
||||
field_name = fields.Char(
|
||||
string='Field Name',
|
||||
required=True,
|
||||
index=True,
|
||||
help='Field identifier used in PDF field mapping (e.g., EmployeeLastName, SIN, Box14)'
|
||||
)
|
||||
|
||||
field_label = fields.Char(
|
||||
string='Field Label',
|
||||
help='Human-readable label for this field (for display purposes)'
|
||||
)
|
||||
|
||||
x_position = fields.Float(
|
||||
string='X Position',
|
||||
required=True,
|
||||
default=0.0,
|
||||
help='X coordinate in points (1 point = 1/72 inch). Origin is at bottom-left corner.'
|
||||
)
|
||||
|
||||
y_position = fields.Float(
|
||||
string='Y Position',
|
||||
required=True,
|
||||
default=0.0,
|
||||
help='Y coordinate in points (1 point = 1/72 inch). Origin is at bottom-left corner. Standard letter size is 612 x 792 points.'
|
||||
)
|
||||
|
||||
font_size = fields.Integer(
|
||||
string='Font Size',
|
||||
required=True,
|
||||
default=10,
|
||||
help='Font size in points (default: 10)'
|
||||
)
|
||||
|
||||
font_name = fields.Char(
|
||||
string='Font Name',
|
||||
required=True,
|
||||
default='Helvetica',
|
||||
help='Font family name (e.g., Helvetica, Times-Roman, Courier)'
|
||||
)
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
help='If unchecked, this field position will not be used when filling PDFs'
|
||||
)
|
||||
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
help='Display order for this field'
|
||||
)
|
||||
|
||||
@api.model
|
||||
def get_coordinates_dict(self, template_type):
|
||||
"""Get coordinates dictionary for a template type.
|
||||
|
||||
Returns dict in format: {'field_name': (x, y, font_size, font_name)}
|
||||
Only includes active positions.
|
||||
"""
|
||||
positions = self.search([
|
||||
('template_type', '=', template_type),
|
||||
('active', '=', True),
|
||||
])
|
||||
|
||||
result = {}
|
||||
for pos in positions:
|
||||
result[pos.field_name] = (
|
||||
pos.x_position,
|
||||
pos.y_position,
|
||||
pos.font_size,
|
||||
pos.font_name,
|
||||
)
|
||||
return result
|
||||
21
fusion_payroll/models/tax_yearly_rate_line.py
Normal file
21
fusion_payroll/models/tax_yearly_rate_line.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class TaxYearlyRateLine(models.Model):
|
||||
_name = 'tax.yearly.rate.line'
|
||||
_description = 'Tax Yearly Rate Line'
|
||||
_order = 'id'
|
||||
|
||||
# === Relational Fields ===
|
||||
tax_id = fields.Many2one(
|
||||
'tax.yearly.rates',
|
||||
string='Tax',
|
||||
ondelete='cascade',
|
||||
)
|
||||
|
||||
# === Tax Bracket Fields ===
|
||||
tax_bracket = fields.Float(string='Tax Bracket')
|
||||
tax_rate = fields.Float(string='Tax Rate')
|
||||
tax_constant = fields.Float(string='Tax Constant')
|
||||
60
fusion_payroll/models/tax_yearly_rates.py
Normal file
60
fusion_payroll/models/tax_yearly_rates.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class TaxYearlyRates(models.Model):
|
||||
_name = 'tax.yearly.rates'
|
||||
_description = 'Yearly Tax Rates'
|
||||
_order = 'id'
|
||||
|
||||
# === Selection Options ===
|
||||
TAX_TYPE_SELECTION = [
|
||||
('federal', 'Federal Taxes'),
|
||||
('provincial', 'Provincial Taxes'),
|
||||
]
|
||||
|
||||
DEDUCTION_TYPE_SELECTION = [
|
||||
('cpp', 'Canada Pension Plan'),
|
||||
('ei', 'Employment Insurance'),
|
||||
]
|
||||
|
||||
# === Core Fields ===
|
||||
fiscal_year = fields.Many2one(
|
||||
'account.fiscal.year',
|
||||
string='Fiscal Year',
|
||||
)
|
||||
tax_type = fields.Selection(
|
||||
selection=TAX_TYPE_SELECTION,
|
||||
string='Tax Type',
|
||||
)
|
||||
ded_type = fields.Selection(
|
||||
selection=DEDUCTION_TYPE_SELECTION,
|
||||
string='Deduction Type',
|
||||
)
|
||||
|
||||
# === Tax Bracket Lines ===
|
||||
tax_yearly_rate_ids = fields.One2many(
|
||||
'tax.yearly.rate.line',
|
||||
'tax_id',
|
||||
string='Tax Lines',
|
||||
)
|
||||
|
||||
# === Federal/Provincial Tax Fields ===
|
||||
fed_tax_credit = fields.Float(string='Federal Tax Credit')
|
||||
provincial_tax_credit = fields.Float(string='Provincial Tax Credit')
|
||||
canada_emp_amount = fields.Float(string='Canada Employment Amount')
|
||||
exemption = fields.Float(string='Exemption Amount')
|
||||
|
||||
# === CPP (Canada Pension Plan) Fields ===
|
||||
cpp_date = fields.Date(string='CPP Date')
|
||||
max_cpp = fields.Float(string='Maximum CPP')
|
||||
emp_contribution_rate = fields.Float(string='Employee Contribution Rate')
|
||||
employer_contribution_rate = fields.Float(string='Employer Contribution Rate')
|
||||
|
||||
# === EI (Employment Insurance) Fields ===
|
||||
ei_date = fields.Date(string='EI Date')
|
||||
ei_rate = fields.Float(string='EI Rate')
|
||||
ei_earnings = fields.Float(string='Maximum EI Earnings')
|
||||
emp_ei_amount = fields.Float(string='Employee EI Amount')
|
||||
employer_ei_amount = fields.Float(string='Employer EI Amount')
|
||||
Reference in New Issue
Block a user