331 lines
15 KiB
Python
331 lines
15 KiB
Python
# -*- 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
|