# -*- coding: utf-8 -*- """Jalali‐aware extensions for the Payroll Dashboard in Odoo 18.""" import random from collections import defaultdict from datetime import date from dateutil.relativedelta import relativedelta import jdatetime from odoo import api, fields, models, _ from odoo.tools import format_amount # --- Constants and mappings ------------------------------------------------ # Mapping of Persian month names (Gregorian) to month numbers GREG_MONTHS_FA = { 'ژانویه': 1, 'ژانویهٔ': 1, 'فوریه': 2, 'مارس': 3, 'آوریل': 4, 'مه': 5, 'مهٔ': 5, 'ژوئن': 6, 'ژوئیه': 7, 'ژوئیهٔ': 7, 'اوت': 8, 'سپتامبر': 9, 'اکتبر': 10, 'نوامبر': 11, 'دسامبر': 12, } # Jalali month names (1–12) JALALI_MONTH_NAMES = [ 'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند', ] # Translation map for Persian digits to ASCII digits PERSIAN_DIGIT_MAP = {ord(ch): str(i) for i, ch in enumerate('۰۱۲۳۴۵۶۷۸۹')} def _to_int(string): """ Convert a string containing Persian or ASCII digits to an integer. """ try: return int(string.translate(PERSIAN_DIGIT_MAP)) except ValueError: # Let any remaining errors bubble up return int(string) class HrPayslip(models.Model): """Extend hr.payslip to support Jalali calendar in dashboard statistics.""" _inherit = 'hr.payslip' def _convert_label_to_jalali(self, label, period): """ Convert a Persian/Gregorian month‐year or ISO date label to Jalali format. :param label: e.g. 'آوریل 2025', '2025-04-01', or '2025' :param period: 'monthly' to return 'فروردین 1404', otherwise '1404' :return: Jalali label or original on failure """ if not isinstance(label, str): return label parts = label.strip().split() # Case 1: Persian month name + year if len(parts) >= 2: month_str, year_str = parts[0], parts[-1] try: year = _to_int(year_str) except ValueError: return label month = GREG_MONTHS_FA.get(month_str) if month: greg_date = date(year, month, 1) else: try: greg_date = date.fromisoformat(label) except ValueError: return label jal = jdatetime.date.fromgregorian(date=greg_date) if period == 'monthly': return f"{JALALI_MONTH_NAMES[jal.month - 1]} {jal.year}" return str(jal.year) # Case 2: Year‐only or ISO full date try: year = _to_int(parts[0]) greg_date = date(year, 12, 31) jal = jdatetime.date.fromgregorian(date=greg_date) return str(jal.year) except (ValueError, IndexError): try: greg_date = date.fromisoformat(label) return str(jdatetime.date.fromgregorian(date=greg_date).year) except ValueError: return label @api.model def get_payroll_dashboard_data(self, sections=None): """ Override to convert batch dates to Jalali when the user lang is fa_IR. """ data = super().get_payroll_dashboard_data(sections) if (self.env.user.lang or self.env.lang) == 'fa_IR': for batch in data.get('batches', []): ds = batch.get('date_start') if not ds: continue # Parse to Gregorian date if isinstance(ds, str): try: greg = fields.Date.from_string(ds) except ValueError: continue elif isinstance(ds, date): greg = ds else: continue jal = jdatetime.date.fromgregorian(date=greg) batch['name'] = f"{JALALI_MONTH_NAMES[jal.month - 1]} {jal.year}" batch['date_start'] = fields.Date.to_string( date(jal.year, jal.month, jal.day) ) return data def _jalali_month_bounds(self, year, month): """ Compute Gregorian start and end dates for a given Jalali month. :return: (start_date, end_date) in Gregorian calendar """ start_j = jdatetime.date(year, month, 1) start_g = start_j.togregorian() end_g = start_g + relativedelta(months=1) - relativedelta(days=1) return start_g, end_g def _jalali_year_bounds(self, year): """ Compute Gregorian start and end dates for a given Jalali year. :return: (start_date, end_date) in Gregorian calendar """ start_j = jdatetime.date(year, 1, 1) start_g = start_j.togregorian() end_g = start_g + relativedelta(years=1) - relativedelta(days=1) return start_g, end_g @api.model def _get_dashboard_stats_employer_cost(self): """ Return employer cost stats in Jalali periods if lang is fa_*. Falls back otherwise. """ lang = (self.env.user.lang or '').lower() if not lang.startswith('fa'): return super()._get_dashboard_stats_employer_cost() today = fields.Date.context_today(self) j_today = jdatetime.date.fromgregorian(date=today) cost_codes = self._get_dashboard_stat_employer_cost_codes() result = { 'type': 'stacked_bar', 'title': _('Employer Cost'), 'label': _('Employer Cost'), 'id': 'employer_cost', 'is_sample': False, 'actions': [], 'data': { 'monthly': defaultdict(lambda: [{} for _ in range(3)]), 'yearly': defaultdict(lambda: [{} for _ in range(3)]), }, } # Process both monthly and yearly in a loop to avoid duplication for period, bounds_func, labels in [ ('monthly', self._jalali_month_bounds, JALALI_MONTH_NAMES), ('yearly', self._jalali_year_bounds, None), ]: for idx, offset in enumerate([-2, -1, 0]): if period == 'monthly': m = j_today.month + offset y = j_today.year if m < 1: m += 12; y -= 1 elif m > 12: m -= 12; y += 1 start_g, end_g = bounds_func(y, m) label = f"{JALALI_MONTH_NAMES[m - 1]} {y}" else: y = j_today.year + offset start_g, end_g = bounds_func(y) label = str(y) slips = self.env['hr.payslip'].search([ ('state', '!=', 'cancel'), ('date_from', '>=', start_g), ('date_to', '<=', end_g), ]) lines = slips._get_line_values(cost_codes.keys()) for code, desc in cost_codes.items(): total = sum(lines[code][slip.id]['total'] for slip in slips) slot = result['data'][period][desc][idx] slot.update({ 'value': round(total, 2), 'formatted_value': format_amount( self.env, total, self.env.company.currency_id ), 'label': label, }) return result @api.model def _get_dashboard_stat_employee_trends(self): """ Return employee‐count trends in Jalali monthly/yearly bars if lang is fa_*. Uses direct SQL for performance and falls back to samples if empty. """ lang = (self.env.user.lang or '').lower() if not lang.startswith('fa'): return super()._get_dashboard_stat_employee_trends() today = fields.Date.context_today(self) j_today = jdatetime.date.fromgregorian(date=today) # Build Jalali periods periods = [] for period, shift in [('monthly', m) for m in (-1, 0, 1)] + [('yearly', y) for y in (-1, 0, 1)]: if period == 'monthly': m = j_today.month + shift y = j_today.year if m < 1: m += 12; y -= 1 elif m > 12: m -= 12; y += 1 start, end = self._jalali_month_bounds(y, m) else: y = j_today.year + shift start, end = self._jalali_year_bounds(y) tag = ('past', 'present', 'future')[shift + 1] periods.append((period, tag, start)) # SQL aggregation counts = {} for period, tag, start in periods: self.env.cr.execute(""" SELECT COUNT(DISTINCT c.employee_id) FROM hr_contract c WHERE (c.date_end >= %s OR c.date_end IS NULL) AND c.date_start <= %s AND c.active = TRUE AND c.company_id IN %s """, (start, start, tuple(self.env.companies.ids))) counts[(period, tag)] = self.env.cr.fetchone()[0] trends = { 'type': 'bar', 'title': _('Employee Trends'), 'label': _('Employee Count'), 'id': 'employees', 'is_sample': False, 'actions': self._get_employee_stats_actions(), 'data': {'monthly': [{}, {}, {}], 'yearly': [{}, {}, {}]}, } idx_map = {'past': 0, 'present': 1, 'future': 2} # Populate real data for (period, tag), count in counts.items(): idx = idx_map[tag] start = periods[idx_map[tag] if period=='monthly' else idx_map[tag]+3][2] jal = jdatetime.date.fromgregorian(date=start) label = ( f"{JALALI_MONTH_NAMES[jal.month - 1]} {jal.year}" if period == 'monthly' else str(jal.year) ) trends['data'][period][idx] = { 'label': label, 'value': count, 'name': label } # Sample fallback if not any(trends['data']['monthly']): trends['is_sample'] = True for period in ('monthly', 'yearly'): for i in range(3): if not trends['data'][period][i]: if period == 'monthly': m = j_today.month + (i - 1) y = j_today.year if m < 1: m += 12; y -= 1 elif m > 12: m -= 12; y += 1 label = f"{JALALI_MONTH_NAMES[m - 1]} {y}" else: label = str(j_today.year + (i - 1)) trends['data'][period][i] = { 'label': label, 'value': random.randint(1000, 1500), 'name': label, } return trends