# -*- 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, }, }