l10n_ir_hr_payroll/models/hr_payslip.py

331 lines
15 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date
from dateutil.relativedelta import relativedelta
from collections import defaultdict
import jdatetime
from odoo import models, api, _
from odoo.fields import Date
from odoo.tools import date_utils
JALALI_MONTHS_FA = {
1: "فروردین", 2: "اردیبهشت", 3: "خرداد", 4: "تیر",
5: "مرداد", 6: "شهریور", 7: "مهر", 8: "آبان",
9: "آذر", 10: "دی", 11: "بهمن", 12: "اسفند",
}
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
def _get_period_name(self, cache):
"""
Return the display name for the payslip period.
For Persian users (fa_IR), format the period using the Jalali calendar
and custom month names; otherwise, use the superclass implementation.
"""
self.ensure_one()
lang = self.employee_id.lang or self.env.user.lang
if lang != 'fa_IR':
return super(type(self), self)._get_period_name(cache)
start = jdatetime.date.fromgregorian(date=self.date_from)
end = jdatetime.date.fromgregorian(date=self.date_to)
schedule = (
self.contract_id.schedule_pay
or self.contract_id.structure_type_id.default_schedule_pay
)
if schedule == 'monthly':
return f"{JALALI_MONTHS_FA[start.month]} {start.year}"
if schedule == 'quarterly':
quarter = (start.month - 1) // 3 + 1
return f"سه‌ماهه {quarter} سال {start.year}"
if schedule == 'semi-annually':
half = 'اول' if start.month <= 6 else 'دوم'
return f"نیمه {half} سال {start.year}"
if schedule == 'annually':
return f"سال {start.year}"
if schedule == 'weekly':
week = int(start.strftime('%U'))
return f"هفته {week} سال {start.year}"
if schedule == 'bi-weekly':
week = int(start.strftime('%U'))
return f"هفته‌های {week} و {week + 1} سال {start.year}"
if schedule == 'bi-monthly':
return f"{JALALI_MONTHS_FA[start.month]} و {JALALI_MONTHS_FA[end.month]} {start.year}"
# Fallback to explicit date range
return f"{start} - {end}"
def _get_schedule_timedelta(self):
"""
Return a relativedelta between date_from and date_to.
For Persian users, compute the end of the period in the Jalali calendar,
convert to Gregorian, and return the difference; otherwise, defer
to the superclass implementation.
"""
self.ensure_one()
schedule = (
self.contract_id.schedule_pay
or self.contract_id.structure_type_id.default_schedule_pay
)
lang = self.employee_id.lang or self.env.user.lang
if lang != 'fa_IR':
return super(type(self), self)._get_schedule_timedelta()
# Convert the start date to Jalali
j_start = jdatetime.date.fromgregorian(date=self.date_from)
# Determine the Jalali end date based on schedule
if schedule == 'monthly':
# Next month first day minus one day
if j_start.month == 12:
ny, nm = j_start.year + 1, 1
else:
ny, nm = j_start.year, j_start.month + 1
j_first_next = jdatetime.date(ny, nm, 1)
j_end = j_first_next - jdatetime.timedelta(days=1)
elif schedule == 'quarterly':
nm = ((j_start.month - 1) // 3 + 1) * 3 + 1
ny = j_start.year + (nm - 1) // 12
nm = ((nm - 1) % 12) + 1
j_first_next = jdatetime.date(ny, nm, 1)
j_end = j_first_next - jdatetime.timedelta(days=1)
elif schedule == 'semi-annually':
nm = j_start.month + 6
ny = j_start.year + (nm - 1) // 12
nm = ((nm - 1) % 12) + 1
j_first_next = jdatetime.date(ny, nm, 1)
j_end = j_first_next - jdatetime.timedelta(days=1)
elif schedule == 'annually':
j_first_next = jdatetime.date(j_start.year + 1, 1, 1)
j_end = j_first_next - jdatetime.timedelta(days=1)
elif schedule == 'weekly':
j_end = j_start + jdatetime.timedelta(days=6)
elif schedule == 'bi-weekly':
j_end = j_start + jdatetime.timedelta(days=13)
elif schedule == 'bi-monthly':
nm = j_start.month + 2
ny = j_start.year + (nm - 1) // 12
nm = ((nm - 1) % 12) + 1
j_first_next = jdatetime.date(ny, nm, 1)
j_end = j_first_next - jdatetime.timedelta(days=1)
else:
# Default monthly fallback
if j_start.month == 12:
ny, nm = j_start.year + 1, 1
else:
ny, nm = j_start.year, j_start.month + 1
j_first_next = jdatetime.date(ny, nm, 1)
j_end = j_first_next - jdatetime.timedelta(days=1)
# Convert back to Gregorian and compute delta
g_start = j_start.togregorian()
g_end = j_end.togregorian()
return relativedelta(g_end, g_start)
def _get_schedule_period_start(self):
"""
Compute the start date of the payslip period.
For Persian users, determine the first day of the period in the Jalali calendar
then convert to Gregorian; otherwise, defer to the superclass.
"""
self.ensure_one()
schedule = (
self.contract_id.schedule_pay
or self.contract_id.structure_type_id.default_schedule_pay
)
lang = self.env.user.lang or 'en_US'
today = date.today()
if lang == 'fa_IR':
j_today = jdatetime.date.fromgregorian(date=today)
if schedule == 'quarterly':
quarter = (j_today.month - 1) // 3
start_month = quarter * 3 + 1
j_start = jdatetime.date(j_today.year, start_month, 1)
elif schedule == 'semi-annually':
sm = 7 if j_today.month > 6 else 1
j_start = jdatetime.date(j_today.year, sm, 1)
elif schedule == 'annually':
j_start = jdatetime.date(j_today.year, 1, 1)
elif schedule == 'weekly':
j_start = j_today - jdatetime.timedelta(days=j_today.weekday())
elif schedule == 'bi-weekly':
wk = int(j_today.strftime('%U'))
j_start = j_today - jdatetime.timedelta(days=j_today.weekday() + 7 * (wk % 2 == 0))
elif schedule == 'bi-monthly':
start_month = ((j_today.month - 1) // 2) * 2 + 1
j_start = jdatetime.date(j_today.year, start_month, 1)
else:
j_start = jdatetime.date(j_today.year, j_today.month, 1)
g_start = j_start.togregorian()
if self.contract_id and g_start < self.contract_id.date_start:
return self.contract_id.date_start
return g_start
return super(type(self), self)._get_schedule_period_start()
@api.depends('date_from', 'date_to', 'struct_id')
def _compute_warning_message(self):
"""
Compute warning messages for the payslip:
1. Contract validity range
2. Future date range beyond end of month
3. Duration mismatch according to schedule
Provides Jalali-aware checks and messages for Persian users.
"""
for slip in self.filtered(lambda p: p.date_to):
slip.warning_message = False
warnings = []
lang = slip.employee_id.lang or slip.env.user.lang
# 1. Contract validity
if slip.contract_id and (
slip.date_from < slip.contract_id.date_start or
(slip.contract_id.date_end and slip.date_to > slip.contract_id.date_end)
):
warnings.append(_("The period selected does not match the contract validity period."))
# 2. Future range warning
if lang == 'fa_IR':
j_today = jdatetime.date.fromgregorian(date=Date.today())
if j_today.month == 12:
ny, nm = j_today.year + 1, 1
else:
ny, nm = j_today.year, j_today.month + 1
j_first_next = jdatetime.date(ny, nm, 1)
j_end_of_month = j_first_next - jdatetime.timedelta(days=1)
g_end_of_month = j_end_of_month.togregorian()
start_str = f"{j_first_next.year}/{j_first_next.month:02}/{j_first_next.day:02}"
end_str = f"{j_end_of_month.year}/{j_end_of_month.month:02}/{j_end_of_month.day:02}"
if slip.date_to > g_end_of_month:
warnings.append(_(
"Work entries may not be generated for the period from %(start)s to %(end)s.",
start=start_str, end=end_str
))
else:
g_end_of_month = date_utils.end_of(Date.today(), 'month')
g_start_next = date_utils.add(g_end_of_month, days=1)
start_str = g_start_next
end_str = slip.date_to
if slip.date_to > g_end_of_month:
warnings.append(_(
"Work entries may not be generated for the period from %(start)s to %(end)s.",
start=start_str, end=end_str
))
# 3. Duration mismatch
schedule = (
slip.contract_id.schedule_pay
or slip.contract_id.structure_type_id.default_schedule_pay
)
if schedule and lang == 'fa_IR':
jd_from = jdatetime.date.fromgregorian(date=slip.date_from)
jd_to = jdatetime.date.fromgregorian(date=slip.date_to)
# Calculate expected end of Jalali period
if schedule == 'monthly':
if jd_from.month == 12:
ny, nm = jd_from.year + 1, 1
else:
ny, nm = jd_from.year, jd_from.month + 1
jfn = jdatetime.date(ny, nm, 1)
expected = jfn - jdatetime.timedelta(days=1)
elif schedule == 'quarterly':
nm_val = jd_from.month + 3
ny_val = jd_from.year + (nm_val - 1) // 12
nm_val = ((nm_val - 1) % 12) + 1
jfn = jdatetime.date(ny_val, nm_val, 1)
expected = jfn - jdatetime.timedelta(days=1)
elif schedule == 'semi-annually':
nm_val = jd_from.month + 6
ny_val = jd_from.year + (nm_val - 1) // 12
nm_val = ((nm_val - 1) % 12) + 1
jfn = jdatetime.date(ny_val, nm_val, 1)
expected = jfn - jdatetime.timedelta(days=1)
elif schedule == 'annually':
jfn = jdatetime.date(jd_from.year + 1, 1, 1)
expected = jfn - jdatetime.timedelta(days=1)
elif schedule == 'weekly':
expected = jd_from + jdatetime.timedelta(days=6)
elif schedule == 'bi-weekly':
expected = jd_from + jdatetime.timedelta(days=13)
elif schedule == 'bi-monthly':
nm_val = jd_from.month + 2
ny_val = jd_from.year + (nm_val - 1) // 12
nm_val = ((nm_val - 1) % 12) + 1
jfn = jdatetime.date(ny_val, nm_val, 1)
expected = jfn - jdatetime.timedelta(days=1)
else:
if jd_from.month == 12:
ny, nm = jd_from.year + 1, 1
else:
ny, nm = jd_from.year, jd_from.month + 1
jfn = jdatetime.date(ny, nm, 1)
expected = jfn - jdatetime.timedelta(days=1)
if jd_to != expected:
warnings.append(_("The duration of the payslip is not accurate according to the structure type."))
else:
if slip.date_from + slip._get_schedule_timedelta() != slip.date_to:
warnings.append(_("The duration of the payslip is not accurate according to the structure type."))
# Finalize warning message
if warnings:
slip.warning_message = "\n".join([_("This payslip can be erroneous :")] + warnings)
def _get_transport_worked_day_lines_from_work_entries(self):
"""
Generate transport allowance worked day lines based on employee attendance entries.
Counts days where worked hours >= 90% of expected hours, then creates a line entry.
"""
transport_lines = []
attendance_type = self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False)
transport_type = self.env['hr.work.entry.type'].search([('code', '=', 'TRANSPORT')], limit=1)
if not attendance_type or not transport_type:
return []
for slip in self:
calendar = slip.contract_id.resource_calendar_id
if not calendar:
continue
# Map expected hours per weekday
expected = defaultdict(float)
for att in calendar.attendance_ids:
expected[att.dayofweek] += att.hour_to - att.hour_from
# Sum actual worked hours per day
entries = self.env['hr.work.entry'].search([
('employee_id', '=', slip.employee_id.id),
('work_entry_type_id', '=', attendance_type.id),
('date_start', '>=', slip.date_from),
('date_stop', '<=', slip.date_to),
])
worked = defaultdict(float)
for entry in entries:
day = entry.date_start.date()
worked[day] += (entry.date_stop - entry.date_start).total_seconds() / 3600
# Count days meeting threshold
count = sum(1 for d, hrs in worked.items() if hrs >= 0.5 * expected[d.weekday()])
if count:
transport_lines.append((0, 0, {
'name': _('ایاب و ذهاب و نهار'),
'sequence': 90,
'code': 'TRANSPORT',
'work_entry_type_id': transport_type.id,
'number_of_days': count,
'number_of_hours': count * 8.0,
'amount': 0.0,
'is_paid': True,
}))
return transport_lines
def _get_new_worked_days_lines(self):
"""
Extend base worked day lines with transport allowance lines.
"""
base = super()._get_new_worked_days_lines()
transport = self._get_transport_worked_day_lines_from_work_entries()
return base + transport