# -*- 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