From 345805fde867cefe304500cc25ef28409d01bd2a Mon Sep 17 00:00:00 2001 From: mory1376 Date: Sun, 8 Feb 2026 00:43:13 +0330 Subject: [PATCH] Initial commit: l10n_ir_hr_payroll (Odoo 18) --- .gitignore | 42 +++ __init__.py | 3 + __manifest__.py | 30 ++ data/hr_payroll_structure_data.xml | 14 + data/hr_payroll_structure_type_data.xml | 10 + data/hr_rule_parameter_data.xml | 105 +++++++ data/hr_salary_rule_category_data.xml | 43 +++ data/hr_salary_rule_data.xml | 377 ++++++++++++++++++++++++ models/__init__.py | 9 + models/hr_contract.py | 46 +++ models/hr_payroll_dashboard.py | 303 +++++++++++++++++++ models/hr_payroll_master_report.py | 136 +++++++++ models/hr_payslip.py | 330 +++++++++++++++++++++ models/hr_payslip_run.py | 69 +++++ models/hr_payslip_worked_days.py | 16 + views/hr_contract_views.xml | 45 +++ 16 files changed, 1578 insertions(+) create mode 100644 .gitignore create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 data/hr_payroll_structure_data.xml create mode 100644 data/hr_payroll_structure_type_data.xml create mode 100644 data/hr_rule_parameter_data.xml create mode 100644 data/hr_salary_rule_category_data.xml create mode 100644 data/hr_salary_rule_data.xml create mode 100644 models/__init__.py create mode 100644 models/hr_contract.py create mode 100644 models/hr_payroll_dashboard.py create mode 100644 models/hr_payroll_master_report.py create mode 100644 models/hr_payslip.py create mode 100644 models/hr_payslip_run.py create mode 100644 models/hr_payslip_worked_days.py create mode 100644 views/hr_contract_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e8e0ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +*.egg-info/ +.eggs/ +.venv/ +venv/ +ENV/ + +# Odoo +*.log +*.pot +*.mo +.session +*.swp +*.swo +*.bak +*.tmp + +# IDEs +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Database / dumps +*.sql +*.dump + +# Node / Assets +node_modules/ +dist/ +build/ + +# Secrets +.env +.env.* diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d6210b1 --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..60a4881 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,30 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + "name": "Iran - Payroll", + "countries": ["ir"], + "category": "Human Resources/Payroll", + "description": """ +Iran Payroll and End of Service rules. +======================================= +- Basic calculation +- End of service calculation +- Other inputs (overtime, salary attachments, etc.) +- Social insurance calculation +- End of service provisions +- Tax break calculations and deductions +- Master payroll export + """, + "depends": ["hr_payroll"], + "auto_install": ["hr_payroll"], + "data": [ + # "security/ir.model.access.csv", + "data/hr_rule_parameter_data.xml", + "data/hr_salary_rule_category_data.xml", + "data/hr_payroll_structure_type_data.xml", + "data/hr_payroll_structure_data.xml", + "data/hr_salary_rule_data.xml", + "views/hr_contract_views.xml", + # "views/hr_payroll_master_report_views.xml", + ], + "license": "OEEL-1", +} diff --git a/data/hr_payroll_structure_data.xml b/data/hr_payroll_structure_data.xml new file mode 100644 index 0000000..f920869 --- /dev/null +++ b/data/hr_payroll_structure_data.xml @@ -0,0 +1,14 @@ + + + + + Iran: Regular Pay + IRMONTHLY + + + + + + + + diff --git a/data/hr_payroll_structure_type_data.xml b/data/hr_payroll_structure_type_data.xml new file mode 100644 index 0000000..9b66734 --- /dev/null +++ b/data/hr_payroll_structure_type_data.xml @@ -0,0 +1,10 @@ + + + + + Iran: Employee + + + + + diff --git a/data/hr_rule_parameter_data.xml b/data/hr_rule_parameter_data.xml new file mode 100644 index 0000000..6846a4e --- /dev/null +++ b/data/hr_rule_parameter_data.xml @@ -0,0 +1,105 @@ + + + + + Transport Allowance Per Day + transport_rate + + + + + 150000.0 + + + + + + Housing Allowance + housing_allowance + + + + 9000000.0 + + + + + + Food Allowance + CONSUMPTION_ALLOWANCE + + + + 22000000.0 + + + + + + Child Allowance + child_allowance + + + + 933558.0 + + + + + + Daily Severance Pay + severance_pay + + + + 116193.0 + + + + + + + Holiday Overtime Rate + holiday_overtime_rate + + + + 180.0 + + + + + + Marital Allowance + marital_allowance + + + + 5000000.0 + + + + + + Good Attendance Allowance + GOODATTENDANCE_ALLOWANCE + + + + 3000000.0 + + + + + Employee Insurance Contribution (%) + employee_insurance_rate + + + + + 7.0 + + + + + \ No newline at end of file diff --git a/data/hr_salary_rule_category_data.xml b/data/hr_salary_rule_category_data.xml new file mode 100644 index 0000000..e60b4ad --- /dev/null +++ b/data/hr_salary_rule_category_data.xml @@ -0,0 +1,43 @@ + + + + + UNINCLUDED ALW + UAWD + + + + پایه + BASIC + + + کمک‌هزینه + ALW + + + تسویه هزینه‌ها + EXPENSE + + + ناخالص + GROSS + + + سهم شرکت + COMP + + + کسورات پرداخت + DED + + + خالص + NET + + + + Tax Totals + TAX + + + diff --git a/data/hr_salary_rule_data.xml b/data/hr_salary_rule_data.xml new file mode 100644 index 0000000..27adbf4 --- /dev/null +++ b/data/hr_salary_rule_data.xml @@ -0,0 +1,377 @@ + + + + + حقوق پایه + + MONTHLY_WAGE + + + none + code + +work_hours = worked_days['WORK100'].number_of_hours if 'WORK100' in worked_days else 0 +leave_120_hours = worked_days['LEAVE120'].number_of_hours if 'LEAVE120' in worked_days else 0 +leave_100_hours = worked_days['LEAVE100'].number_of_hours if 'LEAVE100' in worked_days else 0 + +total_hours = work_hours + leave_120_hours + leave_100_hours +result = contract.wage * total_hours / 8 + + + + + اضافه‌کاری + + OVERTIME_WAGE + + + python + result = worked_days['OVERTIME'].number_of_hours + code + +result = contract.wage * worked_days['OVERTIME'].number_of_hours * 1.4 / 8 + + + + + کمک هزینه مسکن + + HOUSING_ALLOWANCE + + + none + code + +result = payslip._rule_parameter('housing_allowance') + + + + + کمک هزینه عائله‌مندی + + MARITAL_ALLOWANCE + + + python + result = employee.marital == 'married' + code + +result = payslip._rule_parameter('marital_allowance') + + + + + کمک هزینه فرزند + + FAMILY_ALLOWANCE + + + python + result = employee.children > 0 + code + +result = payslip._rule_parameter('child_allowance') * employee.children + + + + + کمک هزینه خواربار + + CONSUMPTION_ALLOWANCE + + + python + result = payslip._rule_parameter('CONSUMPTION_ALLOWANCE') + code + +result = payslip._rule_parameter('CONSUMPTION_ALLOWANCE') + + + + + حضور به موقع + + GOODATTENDANCE_ALLOWANCE + + + python + result = payslip._rule_parameter('GOODATTENDANCE_ALLOWANCE') + code + +result = payslip._rule_parameter('GOODATTENDANCE_ALLOWANCE') + + + + + مانده هزینه‌ها + + REMAINED_EXPENSES + + + python + result = inputs['REIMBURSEMENT'] + code + +result = inputs['REIMBURSEMENT'] + + + + + بیمه سهم کارفرما + + SSP_COMPANY + + + none + code + +# مبنای مشمول بیمه: جمع BASIC و ALW +insurable_base = categories['BASIC'] + categories['ALW'] +# سهم کارفرما ۲۳٪ و سهم کارمند ۷٪ +employer_share = insurable_base * 0.23 +# محاسبه ناخالص مشمول بیمه +result = employer_share +result_name = "ناخالص مشمول بیمه" + + + + + بیمه بیکاری + + UNEMPLOYMENT_SECURITY + + + none + code + +result = 0.0 + + + + + بیمه سهم کارگر + + SSP_EMPLOYEE + + + none + code + +# مبنای مشمول بیمه: جمع BASIC و ALW +insurable_base = categories['BASIC'] + categories['ALW'] +# سهم کارفرما ۲۳٪ و سهم کارمند ۷٪ +employer_share = insurable_base * 0.23 +employee_share = insurable_base * 0.07 +# محاسبه ناخالص مشمول بیمه +result = employee_share + + + + + ناخالص مشمول بیمه + + GROSS_SSP + + + none + code + +# مبنای مشمول بیمه: جمع BASIC و ALW +insurable_base = categories['BASIC'] + categories['ALW'] +# سهم کارفرما ۲۳٪ و سهم کارمند ۷٪ +employer_share = insurable_base * 0.23 +employee_share = insurable_base * 0.07 +# محاسبه ناخالص مشمول بیمه +result = insurable_base + employer_share + employee_share +result_name = "ناخالص مشمول بیمه" + + + + + ناخالص مشمول مالیات + + GROSS_TAXABLE + + + python + result = worked_days['WORK100'].number_of_days + code + +# ۱) مبلغ حقوق ماهانه +mw = contract.wage * (worked_days['WORK100'].number_of_days + (worked_days['LEAVE120'].number_of_days if worked_days['LEAVE120'].number_of_days else 0.0) ) + + +# ۳) ناخالص مشمول مالیات = حقوق ماهانه + اضافه‌کاری +result = mw +# ۴) نام دلخواه برای نمایش +result_name = "ناخالص مشمول مالیات" + + + + + مالیات + + TAX_AMOUNT + + + none + code + +monthly_wage = contract.wage + +if monthly_wage <= 24000000: + result = 0 +elif monthly_wage <= 30000000: + result = (monthly_wage - 24000000) * 0.10 +elif monthly_wage <= 38000000: + result = (6000000 * 0.10) + (monthly_wage - 30000000) * 0.15 +elif monthly_wage <= 50000000: + result = (6000000 * 0.10) + (8000000 * 0.15) + (monthly_wage - 38000000) * 0.20 +elif monthly_wage <= 66667000: + result = (6000000 * 0.10) + (8000000 * 0.15) + (12000000 * 0.20) + (monthly_wage - 50000000) * 0.25 +else: + result = (6000000 * 0.10) + (8000000 * 0.15) + (12000000 * 0.20) + (16667000 * 0.25) + (monthly_wage - 66667000) * 0.30 + + + + + بیمه تکمیلی + + SUPPLEMENTARY_INSURANCE + + + python + result = inputs.ASSIG_SALARY + code + +result = inputs['ASSIG_SALARY'].amount + + + + + قسط وام + + LOAN_INSTALLMENT + + + python + result = inputs['DEDUCTION'] + code + +result = inputs['DEDUCTION'].amount + + + + + قسط مساعده + + ADVANCE_INSTALLMENT + + + none + code + +result = 0.0 + + + + + دیر آمدگی + + DELAYED_CHECKIN + + + python + result = payslip.late_check_in_count_slip >= 2 + code + +result = 0 +result_name = "" +if payslip.late_check_in_count_slip >= 2: + result = inputs['DELAYED_CHCK'].amount +result_name = "دیر آمدگی" + + + + + خالص دریافتی + + NET + + + none + code + +result = categories['BASIC'] + categories['ALW'] + categories['DED'] + + + + End of Service Base Daily - 1404 + + EOSBASE1404_D + 7 + code + +# جدول پایه سنوات روزانه به ازای سال سابقه +service_table = { +0: 0, 1: 94000, 2: 186400, 3: 299128, 4: 435529, 5: 561017, 6: 673958, +7: 764873, 8: 839722, 9: 922359, 10: 976799, 11: 1038860, 12: 1075168, +13: 1099568, 14: 1121934, 15: 1141081, 16: 1161373, 17: 1174944, +18: 1189193, 19: 1204156, 20: 1220615, 21: 1235386, 22: 1248422, +23: 1258705, 24: 1267521, 25: 1275000, 26: 1281327, 27: 1285196, +28: 1288468, 29: 1291772, 30: 1295768, 31: 1299202 +} +years = int(contract.l10n_ir_years_of_service or 0) +daily_amount = service_table.get(years, 0) +date_from = payslip.date_from +date_to = payslip.date_to +month_days = (date_to - date_from).days + 1 +result = daily_amount * month_days + + + + + کم‌کارکرد + + LOW_PERFORMANCE + + + python + +expected_hours = worked_days['WORK100'].number_of_hours if 'WORK100' in worked_days else 0.0 +actual_hours = ( + worked_days['WORK100'].number_of_hours if 'WORK100' in worked_days else 0.0 +) + ( + worked_days['LEAVE120'].number_of_hours if 'LEAVE120' in worked_days else 0.0 +) + ( + worked_days['LEAVE105'].number_of_hours if 'LEAVE105' in worked_days else 0.0 +) + ( + worked_days['LEAVE110'].number_of_hours if 'LEAVE110' in worked_days else 0.0 +) + ( + worked_days['WORK110'].number_of_hours if 'WORK110' in worked_days else 0.0 +) +result = actual_hours < expected_hours + + + code + +expected_hours = worked_days['WORK100'].number_of_hours if 'WORK100' in worked_days else 0.0 +actual_hours = ( + worked_days['WORK100'].number_of_hours if 'WORK100' in worked_days else 0.0 +) + ( + worked_days['LEAVE120'].number_of_hours if 'LEAVE120' in worked_days else 0.0 +) + ( + worked_days['LEAVE105'].number_of_hours if 'LEAVE105' in worked_days else 0.0 +) + ( + worked_days['LEAVE110'].number_of_hours if 'LEAVE110' in worked_days else 0.0 +) + ( + worked_days['WORK110'].number_of_hours if 'WORK110' in worked_days else 0.0 +) +short_hours = max(0.0, expected_hours - actual_hours) +hourly_rate = contract.wage / 30.0 / (contract.resource_calendar_id.hours_per_day or 8.0) +result = -1 * short_hours * hourly_rate +result_name = "کم‌کارکرد" + + + + diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..5cf775f --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import hr_contract +from . import hr_payslip +from . import hr_payroll_dashboard +from . import hr_payslip_worked_days +from . import hr_payslip_run + + diff --git a/models/hr_contract.py b/models/hr_contract.py new file mode 100644 index 0000000..7584684 --- /dev/null +++ b/models/hr_contract.py @@ -0,0 +1,46 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models +from datetime import datetime + + +class HRContract(models.Model): + _inherit = 'hr.contract' + + l10n_ir_other_allowances = fields.Monetary(string='Iran Other Allowances') + l10n_ir_responsibility_allowance = fields.Monetary(string='Iran Responsibility Allowance') + + l10n_ir_years_of_service = fields.Integer( + string='Years of Service', + compute='_compute_years_of_service', + inverse='_inverse_years_of_service', + store=True, + help='Sum of all completed service years for the employee across confirmed contracts.') + + + _sql_constraints = [ + ('check_l10n_ir_years_of_service_positive', 'CHECK(l10n_ir_years_of_service >= 0)', + 'Years of Service must be equal to or greater than 0'), + ] + + def _inverse_years_of_service(self): + # No action needed — just having this allows field to be editable manually + # The manually set value will stay until a recompute is triggered + pass + + @api.depends('employee_id') + def _compute_years_of_service(self): + for contract in self: + total_days = 0 + if contract.employee_id: + employee_contracts = self.env['hr.contract'].search([ + ('employee_id', '=', contract.employee_id.id), + ('state', 'in', ['open', 'close']), + ('date_start', '!=', False), + ('date_end', '!=', False), + ]) + for c in employee_contracts: + duration = (c.date_end - c.date_start).days + total_days += max(duration, 0) + contract.l10n_ir_years_of_service = total_days // 365 + diff --git a/models/hr_payroll_dashboard.py b/models/hr_payroll_dashboard.py new file mode 100644 index 0000000..ee9453a --- /dev/null +++ b/models/hr_payroll_dashboard.py @@ -0,0 +1,303 @@ +# -*- 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 diff --git a/models/hr_payroll_master_report.py b/models/hr_payroll_master_report.py new file mode 100644 index 0000000..5520f25 --- /dev/null +++ b/models/hr_payroll_master_report.py @@ -0,0 +1,136 @@ +import base64 +import io +from collections import defaultdict +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError, UserError +from odoo.tools.misc import xlsxwriter + +XLSX = { + "NUMBER": 0, + "TEXT": 1, + "DATE": 2, + "FORMULA": 3, + "LABEL": 4, +} + + +class HrirMasterReport(models.Model): + _name = "report.l10n_ir_hr_payroll.master" + _description = "Eygpt Master Payroll Report" + + name = fields.Char(compute="_compute_name", store=True) + date_from = fields.Date(required=True, default=fields.Date.today() + relativedelta(day=1)) + date_to = fields.Date(required=True, default=fields.Date.today() + relativedelta(day=1, months=1, days=-1)) + xlsx_file = fields.Binary(string="Report", readonly=True) + xlsx_filename = fields.Char(readonly=True) + period_has_payslips = fields.Boolean(compute="_compute_period_has_payslips") + + @api.model + def default_get(self, field_list=None): + if self.env.company.country_id.code != "ir": + raise UserError(_("You must be logged in an iran company to use this feature")) + return super().default_get(field_list) + + @api.depends("date_from", "date_to") + def _compute_name(self): + for report in self: + report.name = _( + "Master Report %(date_from)s - %(date_to)s", date_from=report.date_from, date_to=report.date_to + ) + + @api.depends("date_from", "date_to") + def _compute_period_has_payslips(self): + for report in self: + payslips = report.env["hr.payslip"].search( + [ + ("date_from", ">=", report.date_from), + ("date_to", "<=", report.date_to), + ("company_id", "=", report.env.company.id), + ("state", "in", ["done", "paid"]), + ] + ) + report.period_has_payslips = bool(payslips) + + @api.constrains("date_from", "date_to") + def _check_dates(self): + for report in self: + if report.date_from > report.date_to: + raise ValidationError(_("The starting date must be before or equal to the ending date")) + + @api.model + def _write_row(self, worksheet, row_index, row, formats): + for i, (formatting, *value) in enumerate(row): + if formatting == XLSX["TEXT"]: + worksheet.write(row_index, i, *value, formats[XLSX["TEXT"]]) + elif formatting == XLSX["DATE"]: + worksheet.write_datetime(row_index, i, *value, formats[XLSX["DATE"]]) + + def action_generate_report(self): + company = self.env.company + if company.country_code != "ir": + raise UserError(_("You must be logged in an iran company to use this feature")) + + labels = [_("Employee ID"), _("Employee Name"), _("Joining Date"), _("Department"), _("Job Designation")] + + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output) + + formats = { + XLSX["TEXT"]: workbook.add_format({"border": 1}), + XLSX["DATE"]: workbook.add_format({"border": 1, "num_format": "dd/mm/yyyy"}), + XLSX["LABEL"]: workbook.add_format({"border": 1, "bold": True}), + } + + payslips = self.env["hr.payslip"].search( + [ + ("date_from", ">=", self.date_from), + ("date_to", "<=", self.date_to), + ("company_id", "=", company.id), + ("state", "in", ["done", "paid"]), + ] + ) + if not payslips: + raise ValidationError(_("There are no eligible payslips for that period of time")) + payslips_data = defaultdict(dict) + for payslip in payslips: + payslips_data[payslip.struct_id][payslip.employee_id] = payslip + + for struct in payslips_data: + worksheet = workbook.add_worksheet(name=struct.name) + + i = 1 + for employee in payslips_data[struct]: + joining_date = "" + if employee.contract_id.date_start: + joining_date = datetime.strptime( + employee.contract_id.date_start.strftime("%Y-%m-%d"), "%Y-%m-%d" + ).date() + + row = [ + (XLSX["TEXT"], employee.id), + (XLSX["TEXT"], employee.name), + (XLSX["DATE"], joining_date) if joining_date else (XLSX["TEXT"], ""), + (XLSX["TEXT"], employee.department_id.name or ""), + (XLSX["TEXT"], employee.job_title or ""), + ] + if i == 1: + labels.extend(payslips_data[struct][employee].line_ids.mapped("name")) + row.extend( + (XLSX["TEXT"], company.currency_id.format(t)) + for t in payslips_data[struct][employee].line_ids.mapped("total") + ) + self._write_row(worksheet, i, row, formats) + i += 1 + + for col, label in enumerate(labels): + worksheet.write(0, col, label, formats[XLSX["LABEL"]]) + worksheet.set_column(0, len(labels) - 1, 20) + + workbook.close() + xlsx_data = output.getvalue() + self.xlsx_file = base64.encodebytes(xlsx_data) + self.xlsx_filename = f"{self.name}.xlsx" diff --git a/models/hr_payslip.py b/models/hr_payslip.py new file mode 100644 index 0000000..8524306 --- /dev/null +++ b/models/hr_payslip.py @@ -0,0 +1,330 @@ +# -*- 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 diff --git a/models/hr_payslip_run.py b/models/hr_payslip_run.py new file mode 100644 index 0000000..b9aea9d --- /dev/null +++ b/models/hr_payslip_run.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# Jalali-aware inheritance for hr.payslip.run defaults + +import jdatetime +from datetime import date, datetime +from dateutil.relativedelta import relativedelta + +from odoo import models, fields, api, _ + + +class HrPayslipRun(models.Model): + _inherit = 'hr.payslip.run' + + # Redefine fields with Jalali-aware defaults + date_start = fields.Date( + string='Date From', required=True, + default=lambda self: self._default_date_start() + ) + date_end = fields.Date( + string='Date To', required=True, + default=lambda self: self._default_date_end() + ) + + @api.model + def _default_date_start(self): + """ + Compute the first day of the current month. + Uses Jalali calendar for Persian users (fa_IR), otherwise Gregorian. + Returns an ISO-formatted date string. + """ + lang = self.env.user.lang or 'en_US' + if lang == 'fa_IR': + j_today = jdatetime.date.fromgregorian(date=date.today()) + j_start = jdatetime.date(j_today.year, j_today.month, 1) + return fields.Date.to_string(j_start.togregorian()) + return fields.Date.to_string(date.today().replace(day=1)) + + @api.model + def _default_date_end(self): + """ + Compute the last day of the current month. + Uses Jalali calendar for Persian users (fa_IR), otherwise Gregorian. + Returns an ISO-formatted date string. + """ + lang = self.env.user.lang or 'en_US' + if lang == 'fa_IR': + j_today = jdatetime.date.fromgregorian(date=date.today()) + if j_today.month == 12: + j_next = jdatetime.date(j_today.year + 1, 1, 1) + else: + j_next = jdatetime.date(j_today.year, j_today.month + 1, 1) + j_end = j_next - jdatetime.timedelta(days=1) + return fields.Date.to_string(j_end.togregorian()) + # Gregorian fallback: last day of this month + end_date = (datetime.now() + relativedelta(months=1, day=1, days=-1)).date() + return fields.Date.to_string(end_date) + + @api.model + def default_get(self, fields_list): + """ + Override default_get to inject Jalali-aware defaults + for date_start and date_end on record creation. + """ + res = super(HrPayslipRun, self).default_get(fields_list) + if 'date_start' in fields_list: + res['date_start'] = self._default_date_start() + if 'date_end' in fields_list: + res['date_end'] = self._default_date_end() + return res diff --git a/models/hr_payslip_worked_days.py b/models/hr_payslip_worked_days.py new file mode 100644 index 0000000..30db597 --- /dev/null +++ b/models/hr_payslip_worked_days.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class HrPayslipWorkedDays(models.Model): + _inherit = 'hr.payslip.worked_days' + + @api.depends('is_paid', 'is_credit_time', 'number_of_hours', 'payslip_id', 'contract_id.wage', 'payslip_id.sum_worked_hours') + def _compute_amount(self): + super()._compute_amount() + for worked_days in self: + if worked_days.code == 'TRANSPORT' and worked_days.is_paid: + rate = self.env['hr.rule.parameter']._get_parameter_from_code('transport_rate', worked_days.payslip_id.date_to) + worked_days.amount = worked_days.number_of_days * rate diff --git a/views/hr_contract_views.xml b/views/hr_contract_views.xml new file mode 100644 index 0000000..3d9c283 --- /dev/null +++ b/views/hr_contract_views.xml @@ -0,0 +1,45 @@ + + + + hr.contract.form.inherit.ir + hr.contract + + + + + + + + + + + + + + + + + hr.payslip.run.form.jalali + hr.payslip.run + + + + + مثال: فروردین ۱۴۰۴ + + + + +