Initial commit: l10n_ir_hr_payroll (Odoo 18)
This commit is contained in:
commit
345805fde8
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal 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
3
__init__.py
Normal 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
30
__manifest__.py
Normal 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",
|
||||||
|
}
|
||||||
14
data/hr_payroll_structure_data.xml
Normal file
14
data/hr_payroll_structure_data.xml
Normal 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>
|
||||||
10
data/hr_payroll_structure_type_data.xml
Normal file
10
data/hr_payroll_structure_type_data.xml
Normal 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>
|
||||||
105
data/hr_rule_parameter_data.xml
Normal file
105
data/hr_rule_parameter_data.xml
Normal 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>
|
||||||
43
data/hr_salary_rule_category_data.xml
Normal file
43
data/hr_salary_rule_category_data.xml
Normal 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>
|
||||||
377
data/hr_salary_rule_data.xml
Normal file
377
data/hr_salary_rule_data.xml
Normal 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 <= 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
|
||||||
|
</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 < 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
9
models/__init__.py
Normal 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
46
models/hr_contract.py
Normal 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
|
||||||
|
|
||||||
303
models/hr_payroll_dashboard.py
Normal file
303
models/hr_payroll_dashboard.py
Normal file
@ -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
|
||||||
136
models/hr_payroll_master_report.py
Normal file
136
models/hr_payroll_master_report.py
Normal 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
330
models/hr_payslip.py
Normal 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
69
models/hr_payslip_run.py
Normal 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
|
||||||
16
models/hr_payslip_worked_days.py
Normal file
16
models/hr_payslip_worked_days.py
Normal 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
|
||||||
45
views/hr_contract_views.xml
Normal file
45
views/hr_contract_views.xml
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user