466 lines
17 KiB
Python
466 lines
17 KiB
Python
# -*- 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,
|
|
},
|
|
}
|