Initial commit: l10n_ir_hr_payroll (Odoo 18)

This commit is contained in:
mory1376 2026-02-08 00:43:13 +03:30
commit 345805fde8
16 changed files with 1578 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@ -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.*

3
__init__.py Normal file
View File

@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models

30
__manifest__.py Normal file
View File

@ -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",
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="hr_payroll_structure_ir_employee_salary" model="hr.payroll.structure">
<field name="name">Iran: Regular Pay</field>
<field name="code">IRMONTHLY</field>
<field name="country_id" ref="base.ir"/>
<field name="type_id" ref="structure_type_employee_ir"/>
</record>
<record id="l10n_ir_hr_payroll.structure_type_employee_ir" model="hr.payroll.structure.type">
<field name="default_struct_id" ref="hr_payroll_structure_ir_employee_salary"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="structure_type_employee_ir" model="hr.payroll.structure.type">
<field name="name">Iran: Employee</field>
<field name="default_resource_calendar_id" ref="resource.resource_calendar_std"/>
<field name="country_id" ref="base.ir"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,105 @@
<odoo>
<data>
<!-- Transport Allowance Daily Rate Parameter -->
<record id="rule_parameter_transport_rate" model="hr.rule.parameter">
<field name="name">Transport Allowance Per Day</field>
<field name="code">transport_rate</field>
<field name="country_id" ref="base.ir"/>
</record>
<record id="rule_parameter_value_transport_rate_1404" model="hr.rule.parameter.value">
<field name="parameter_value">150000.0</field>
<field name="rule_parameter_id" ref="rule_parameter_transport_rate"/>
<field name="date_from" eval="datetime(2025, 3, 20).date()"/>
</record>
<record id="rule_parameter_housing_allowance" model="hr.rule.parameter">
<field name="name">Housing Allowance</field>
<field name="code">housing_allowance</field>
<field name="country_id" ref="base.ir"/>
</record>
<record id="rule_parameter_value_housing_allowance" model="hr.rule.parameter.value">
<field name="parameter_value">9000000.0</field>
<field name="rule_parameter_id" ref="rule_parameter_housing_allowance"/>
<field name="date_from" eval="datetime(2025, 3, 20).date()"/>
</record>
<record id="rule_parameter_consumption_allowance" model="hr.rule.parameter">
<field name="name">Food Allowance</field>
<field name="code">CONSUMPTION_ALLOWANCE</field>
<field name="country_id" ref="base.ir"/>
</record>
<record id="rule_parameter_value_consumption_allowance" model="hr.rule.parameter.value">
<field name="parameter_value">22000000.0</field>
<field name="rule_parameter_id" ref="rule_parameter_consumption_allowance"/>
<field name="date_from" eval="datetime(2025, 3, 20).date()"/>
</record>
<record id="rule_parameter_child_allowance" model="hr.rule.parameter">
<field name="name">Child Allowance</field>
<field name="code">child_allowance</field>
<field name="country_id" ref="base.ir"/>
</record>
<record id="rule_parameter_value_child_allowance" model="hr.rule.parameter.value">
<field name="parameter_value">933558.0</field>
<field name="rule_parameter_id" ref="rule_parameter_child_allowance"/>
<field name="date_from" eval="datetime(2025, 3, 20).date()"/>
</record>
<record id="rule_parameter_severance_pay" model="hr.rule.parameter">
<field name="name">Daily Severance Pay</field>
<field name="code">severance_pay</field>
<field name="country_id" ref="base.ir"/>
</record>
<record id="rule_parameter_value_severance_pay" model="hr.rule.parameter.value">
<field name="parameter_value">116193.0</field>
<field name="rule_parameter_id" ref="rule_parameter_severance_pay"/>
<field name="date_from" eval="datetime(2025, 3, 20).date()"/>
</record>
<record id="rule_parameter_holiday_overtime_rate" model="hr.rule.parameter">
<field name="name">Holiday Overtime Rate</field>
<field name="code">holiday_overtime_rate</field>
<field name="country_id" ref="base.ir"/>
</record>
<record id="rule_parameter_value_holiday_overtime_rate" model="hr.rule.parameter.value">
<field name="parameter_value">180.0</field>
<field name="rule_parameter_id" ref="rule_parameter_holiday_overtime_rate"/>
<field name="date_from" eval="datetime(2025, 3, 20).date()"/>
</record>
<record id="rule_parameter_marital_allowance" model="hr.rule.parameter">
<field name="name">Marital Allowance</field>
<field name="code">marital_allowance</field>
<field name="country_id" ref="base.ir"/>
</record>
<record id="rule_parameter_value_marital_allowance" model="hr.rule.parameter.value">
<field name="parameter_value">5000000.0</field>
<field name="rule_parameter_id" ref="rule_parameter_marital_allowance"/>
<field name="date_from" eval="datetime(2025, 3, 20).date()"/>
</record>
<record id="rule_parameter_goodattenace_allowance" model="hr.rule.parameter">
<field name="name">Good Attendance Allowance</field>
<field name="code">GOODATTENDANCE_ALLOWANCE</field>
<field name="country_id" ref="base.ir"/>
</record>
<record id="rule_parameter_value_goodattenace_allowance" model="hr.rule.parameter.value">
<field name="parameter_value">3000000.0</field>
<field name="rule_parameter_id" ref="rule_parameter_goodattenace_allowance"/>
<field name="date_from" eval="datetime(2025, 3, 20).date()"/>
</record>
<record id="rule_parameter_employee_insurance_rate" model="hr.rule.parameter">
<field name="name">Employee Insurance Contribution (%)</field>
<field name="code">employee_insurance_rate</field>
<field name="country_id" ref="base.ir"/>
</record>
<record id="rule_parameter_value_employee_insurance_rate" model="hr.rule.parameter.value">
<field name="parameter_value">7.0</field> <!-- 7% سهم کارمند -->
<field name="rule_parameter_id" ref="rule_parameter_employee_insurance_rate"/>
<field name="date_from" eval="datetime(2024, 3, 20).date()"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="hr_salary_rule_category_ua_unincluded" model="hr.salary.rule.category">
<field name="name">UNINCLUDED ALW</field>
<field name="code">UAWD</field>
</record>
<!-- Categories (hr.salary.rule.category) -->
<record id="hr_salary_rule_category_ir_basic" model="hr.salary.rule.category">
<field name="name">پایه</field>
<field name="code">BASIC</field>
</record>
<record id="hr_salary_rule_category_ir_allowance" model="hr.salary.rule.category">
<field name="name">کمک‌هزینه</field>
<field name="code">ALW</field>
</record>
<record id="hr_salary_rule_category_ir_expense_settlement" model="hr.salary.rule.category">
<field name="name">تسویه هزینه‌ها</field>
<field name="code">EXPENSE</field>
</record>
<record id="hr_salary_rule_category_ir_gross" model="hr.salary.rule.category">
<field name="name">ناخالص</field>
<field name="code">GROSS</field>
</record>
<record id="hr_salary_rule_category_ir_company_share" model="hr.salary.rule.category">
<field name="name">سهم شرکت</field>
<field name="code">COMP</field>
</record>
<record id="hr_salary_rule_category_ir_deduction" model="hr.salary.rule.category">
<field name="name">کسورات پرداخت</field>
<field name="code">DED</field>
</record>
<record id="hr_salary_rule_category_ir_net" model="hr.salary.rule.category">
<field name="name">خالص</field>
<field name="code">NET</field>
</record>
<!-- Tax totals category (if needed) -->
<record id="hr_salary_rule_category_ir_tax_totals" model="hr.salary.rule.category">
<field name="name">Tax Totals</field>
<field name="code">TAX</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,377 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data>
<record id="hr_salary_rule_ir_monthly_wage" model="hr.salary.rule">
<field name="name">حقوق پایه</field>
<field name="sequence" eval="1"/>
<field name="code">MONTHLY_WAGE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_basic"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
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
</field>
</record>
<record id="hr_salary_rule_ir_overtime_wage" model="hr.salary.rule">
<field name="name">اضافه‌کاری</field>
<field name="sequence" eval="2"/>
<field name="code">OVERTIME_WAGE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_basic"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_python">result = worked_days['OVERTIME'].number_of_hours</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = contract.wage * worked_days['OVERTIME'].number_of_hours * 1.4 / 8
</field>
</record>
<record id="hr_salary_rule_ir_housing_allowance" model="hr.salary.rule">
<field name="name">کمک هزینه مسکن</field>
<field name="sequence" eval="10"/>
<field name="code">HOUSING_ALLOWANCE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_allowance"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = payslip._rule_parameter('housing_allowance')
</field>
</record>
<record id="hr_salary_rule_ir_marital_allowance" model="hr.salary.rule">
<field name="name">کمک هزینه عائله‌مندی</field>
<field name="sequence" eval="11"/>
<field name="code">MARITAL_ALLOWANCE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_allowance"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_python">result = employee.marital == 'married'</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = payslip._rule_parameter('marital_allowance')
</field>
</record>
<record id="hr_salary_rule_ir_family_allowance" model="hr.salary.rule">
<field name="name">کمک هزینه فرزند</field>
<field name="sequence" eval="12"/>
<field name="code">FAMILY_ALLOWANCE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_allowance"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_python">result = employee.children > 0</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = payslip._rule_parameter('child_allowance') * employee.children
</field>
</record>
<record id="hr_salary_rule_ir_CONSUMPTION_ALLOWANCE" model="hr.salary.rule">
<field name="name">کمک هزینه خواربار</field>
<field name="sequence" eval="13"/>
<field name="code">CONSUMPTION_ALLOWANCE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_allowance"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._rule_parameter('CONSUMPTION_ALLOWANCE')</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = payslip._rule_parameter('CONSUMPTION_ALLOWANCE')
</field>
</record>
<record id="hr_salary_rule_ir_GOODATTENACE_ALLOWANCE" model="hr.salary.rule">
<field name="name">حضور به موقع</field>
<field name="sequence" eval="14"/>
<field name="code">GOODATTENDANCE_ALLOWANCE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_allowance"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._rule_parameter('GOODATTENDANCE_ALLOWANCE')</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = payslip._rule_parameter('GOODATTENDANCE_ALLOWANCE')
</field>
</record>
<record id="hr_salary_rule_ir_remained_expenses" model="hr.salary.rule">
<field name="name">مانده هزینه‌ها</field>
<field name="sequence" eval="20"/>
<field name="code">REMAINED_EXPENSES</field>
<field name="category_id" ref="hr_salary_rule_category_ir_expense_settlement"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_python">result = inputs['REIMBURSEMENT']</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = inputs['REIMBURSEMENT']
</field>
</record>
<record id="hr_salary_rule_ir_ssp_company" model="hr.salary.rule">
<field name="name">بیمه سهم کارفرما</field>
<field name="sequence" eval="50"/>
<field name="code">SSP_COMPANY</field>
<field name="category_id" ref="hr_salary_rule_category_ir_company_share"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
# مبنای مشمول بیمه: جمع BASIC و ALW
insurable_base = categories['BASIC'] + categories['ALW']
# سهم کارفرما ۲۳٪ و سهم کارمند ۷٪
employer_share = insurable_base * 0.23
# محاسبه ناخالص مشمول بیمه
result = employer_share
result_name = "ناخالص مشمول بیمه"
</field>
</record>
<record id="hr_salary_rule_ir_unemployment_security" model="hr.salary.rule">
<field name="name">بیمه بیکاری</field>
<field name="sequence" eval="51"/>
<field name="code">UNEMPLOYMENT_SECURITY</field>
<field name="category_id" ref="hr_salary_rule_category_ir_company_share"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = 0.0
</field>
</record>
<record id="hr_salary_rule_ir_ssp_employee" model="hr.salary.rule">
<field name="name">بیمه سهم کارگر</field>
<field name="sequence" eval="52"/>
<field name="code">SSP_EMPLOYEE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_deduction"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
# مبنای مشمول بیمه: جمع BASIC و ALW
insurable_base = categories['BASIC'] + categories['ALW']
# سهم کارفرما ۲۳٪ و سهم کارمند ۷٪
employer_share = insurable_base * 0.23
employee_share = insurable_base * 0.07
# محاسبه ناخالص مشمول بیمه
result = employee_share
</field>
</record>
<record id="hr_salary_rule_ir_gross_ssp" model="hr.salary.rule">
<field name="name">ناخالص مشمول بیمه</field>
<field name="sequence" eval="60"/>
<field name="code">GROSS_SSP</field>
<field name="category_id" ref="hr_salary_rule_category_ir_gross"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
# مبنای مشمول بیمه: جمع 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 = "ناخالص مشمول بیمه"
</field>
</record>
<record id="hr_salary_rule_ir_gross_taxable" model="hr.salary.rule">
<field name="name">ناخالص مشمول مالیات</field>
<field name="sequence" eval="61"/>
<field name="code">GROSS_TAXABLE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_gross"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_python">result = worked_days['WORK100'].number_of_days</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
# ۱) مبلغ حقوق ماهانه
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 = "ناخالص مشمول مالیات"
</field>
</record>
<record id="hr_salary_rule_ir_tax_amount" model="hr.salary.rule">
<field name="name">مالیات</field>
<field name="sequence" eval="70"/>
<field name="code">TAX_AMOUNT</field>
<field name="category_id" ref="hr_salary_rule_category_ir_deduction"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
monthly_wage = contract.wage
if monthly_wage &lt;= 24000000:
result = 0
elif monthly_wage &lt;= 30000000:
result = (monthly_wage - 24000000) * 0.10
elif monthly_wage &lt;= 38000000:
result = (6000000 * 0.10) + (monthly_wage - 30000000) * 0.15
elif monthly_wage &lt;= 50000000:
result = (6000000 * 0.10) + (8000000 * 0.15) + (monthly_wage - 38000000) * 0.20
elif monthly_wage &lt;= 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
</field>
</record>
<record id="hr_salary_rule_ir_supplementary_insurance" model="hr.salary.rule">
<field name="name">بیمه تکمیلی</field>
<field name="sequence" eval="71"/>
<field name="code">SUPPLEMENTARY_INSURANCE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_deduction"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_python">result = inputs.ASSIG_SALARY</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = inputs['ASSIG_SALARY'].amount
</field>
</record>
<record id="hr_salary_rule_ir_loan_installment" model="hr.salary.rule">
<field name="name">قسط وام</field>
<field name="sequence" eval="72"/>
<field name="code">LOAN_INSTALLMENT</field>
<field name="category_id" ref="hr_salary_rule_category_ir_deduction"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_python">result = inputs['DEDUCTION']</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = inputs['DEDUCTION'].amount
</field>
</record>
<record id="hr_salary_rule_ir_advance_installment" model="hr.salary.rule">
<field name="name">قسط مساعده</field>
<field name="sequence" eval="73"/>
<field name="code">ADVANCE_INSTALLMENT</field>
<field name="category_id" ref="hr_salary_rule_category_ir_deduction"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = 0.0
</field>
</record>
<record id="hr_salary_rule_ir_delayed_checkin" model="hr.salary.rule">
<field name="name">دیر آمدگی</field>
<field name="sequence" eval="74"/>
<field name="code">DELAYED_CHECKIN</field>
<field name="category_id" ref="hr_salary_rule_category_ir_deduction"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip.late_check_in_count_slip >= 2</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = 0
result_name = ""
if payslip.late_check_in_count_slip >= 2:
result = inputs['DELAYED_CHCK'].amount
result_name = "دیر آمدگی"
</field>
</record>
<record id="hr_salary_rule_ir_net" model="hr.salary.rule">
<field name="name">خالص دریافتی</field>
<field name="sequence" eval="100"/>
<field name="code">NET</field>
<field name="category_id" ref="hr_salary_rule_category_ir_net"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
result = categories['BASIC'] + categories['ALW'] + categories['DED']
</field>
</record>
<record id="iran_end_of_service_base_daily_1404" model="hr.salary.rule">
<field name="name">End of Service Base Daily - 1404</field>
<field name="category_id" ref="hr_payroll.ALW"/>
<field name="code">EOSBASE1404_D</field>
<field name="sequence">7</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
# جدول پایه سنوات روزانه به ازای سال سابقه
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
</field>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
</record>
<record id="hr_salary_rule_ir_low_performance" model="hr.salary.rule">
<field name="name">کم‌کارکرد</field>
<field name="sequence" eval="76"/>
<field name="code">LOW_PERFORMANCE</field>
<field name="category_id" ref="hr_salary_rule_category_ir_deduction"/>
<field name="struct_id" ref="l10n_ir_hr_payroll.hr_payroll_structure_ir_employee_salary"/>
<field name="condition_select">python</field>
<field name="condition_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 &lt; expected_hours
</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
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 = "کم‌کارکرد"
</field>
</record>
</data>
</odoo>

9
models/__init__.py Normal file
View File

@ -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

46
models/hr_contract.py Normal file
View File

@ -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

View File

@ -0,0 +1,303 @@
# -*- coding: utf-8 -*-
"""Jalaliaware 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 (112)
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 monthyear 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: Yearonly 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 employeecount 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

View File

@ -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"

330
models/hr_payslip.py Normal file
View File

@ -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

69
models/hr_payslip_run.py Normal file
View File

@ -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

View File

@ -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

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_hr_contract_form_inherit_ir" model="ir.ui.view">
<field name="name">hr.contract.form.inherit.ir</field>
<field name="model">hr.contract</field>
<field name="inherit_id" ref="hr_contract.hr_contract_view_form"/>
<field name="arch" type="xml">
<group name="salary" position="inside">
<field name="country_code" invisible="1"/>
<label for="l10n_ir_other_allowances" invisible="country_code != 'IR'" string="Other Allowances"/>
<div class="o_row mw-50" name="l10n_ir_other_allowances" invisible="country_code != 'IR'">
<field name="l10n_ir_other_allowances" class="oe_inline o_hr_narrow_field" nolabel="1"/>
<span>/ month</span>
</div>
<label for="l10n_ir_responsibility_allowance" invisible="country_code != 'IR'"
string="Responsibility Allowance"/>
<div class="o_row mw-50" name="l10n_ir_responsibility_allowance" invisible="country_code != 'IR'">
<field name="l10n_ir_responsibility_allowance" class="oe_inline o_hr_narrow_field" nolabel="1"/>
<span>/ month</span>
</div>
</group>
<group name="yearly_benefits" position="after">
<group name="ir_end_of_service" string="End of Service Benefit" invisible="country_code != 'IR'">
<field name="l10n_ir_years_of_service"/>
</group>
</group>
</field>
</record>
<!-- Inherit the existing form view to change the placeholder -->
<record id="hr_payslip_run_form_jalali" model="ir.ui.view">
<field name="name">hr.payslip.run.form.jalali</field>
<field name="model">hr.payslip.run</field>
<field name="inherit_id" ref="hr_payroll.hr_payslip_run_form"/>
<field name="arch" type="xml">
<!-- Change the placeholder on the Batch Name field -->
<xpath expr="//field[@name='name']" position="attributes">
<attribute name="placeholder">مثال: فروردین ۱۴۰۴</attribute>
</xpath>
</field>
</record>
</odoo>