l10n_ir_hr_payroll/models/hr_payroll_dashboard.py

304 lines
11 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
"""Jalaliaware 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 (112)
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 monthyear 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: Yearonly 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 employeecount 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