304 lines
11 KiB
Python
304 lines
11 KiB
Python
# -*- 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
|