Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions payroll_contract_advantages/README.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association

===========================
Payroll Contract Advantages
===========================
Expand All @@ -17,7 +13,7 @@ Payroll Contract Advantages
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpayroll-lightgray.png?logo=github
Expand All @@ -37,6 +33,11 @@ set in contract form. The advantages can be set in the contract form as
a list of advantages templates. Then it can be used in the calculation
of the salary rules.

Each advantage has a computation mode (fixed value, percentage of a
contract field, or Python expression); the amount is re-evaluated per
payslip. The default fixed mode reproduces the historical behaviour.
Template bounds are enforced on the amount.

**Table of contents**

.. contents::
Expand All @@ -51,6 +52,14 @@ Usage
this contract, default value will be populated but you can change it.
- Then in the salary rules, access this value using
current_contract.advantages.[ADVANTAGE_CODE] (without brackets)
- On the template, choose a computation mode (fixed value, percentage of
a contract field, or Python code). The definition is copied onto the
advantage and can be tuned per contract.
- The amount is re-evaluated for each payslip. Python formulas expose
``advantage``, ``contract``, ``employee``, ``payslip`` and must set
``result``.
- Bounds are enforced on the amount; a non-numeric formula result raises
an error.

Bug Tracker
===========
Expand All @@ -75,6 +84,7 @@ Contributors

- Nimarosa (Nicolas Rodriguez) <nicolasrsande@gmail.com>
- Cristiano Mafra Junior <cristiano.mafra@escodoo.com.br>
- Cyril VINH-TUNG <cyril@invitu.com>

Maintainers
-----------
Expand Down
1 change: 1 addition & 0 deletions payroll_contract_advantages/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"views/hr_contract_advantage_views.xml",
"views/hr_contract_views.xml",
],
"demo": ["demo/payroll_contract_advantages_demo.xml"],
"application": True,
"maintainers": ["nimarosa"],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Advantage Templates: one per computation mode -->
<record id="advantage_template_meal" model="hr.contract.advantage.template">
<field name="name">Meal Voucher</field>
<field name="code">MEAL</field>
<field name="lower_bound">0.0</field>
<field name="upper_bound">500.0</field>
<field name="default_value">10.0</field>
<field name="computation_mode">fixed</field>
</record>
<record id="advantage_template_housing" model="hr.contract.advantage.template">
<field name="name">Housing Allowance</field>
<field name="code">HOUS</field>
<field name="lower_bound">0.0</field>
<field name="upper_bound">5000.0</field>
<field name="computation_mode">percentage</field>
<field name="percentage">5.0</field>
<field name="percentage_base">wage</field>
</record>
<record id="advantage_template_phone" model="hr.contract.advantage.template">
<field name="name">Phone Allowance</field>
<field name="code">PHONE</field>
<field name="lower_bound">0.0</field>
<field name="upper_bound">1000.0</field>
<field name="computation_mode">python</field>
<field name="python_code">result = contract.wage * 0.02</field>
</record>
<!-- Advantages on Mitchell Admin's contract, one per mode -->
<record id="advantage_meal_admin" model="hr.contract.advantage">
<field name="contract_id" ref="hr_contract.hr_contract_admin_new" />
<field name="advantage_template_id" ref="advantage_template_meal" />
<field name="computation_mode">fixed</field>
<field name="amount">10.0</field>
</record>
<record id="advantage_housing_admin" model="hr.contract.advantage">
<field name="contract_id" ref="hr_contract.hr_contract_admin_new" />
<field name="advantage_template_id" ref="advantage_template_housing" />
<field name="computation_mode">percentage</field>
<field name="percentage">5.0</field>
<field name="percentage_base">wage</field>
</record>
<record id="advantage_phone_admin" model="hr.contract.advantage">
<field name="contract_id" ref="hr_contract.hr_contract_admin_new" />
<field name="advantage_template_id" ref="advantage_template_phone" />
<field name="computation_mode">python</field>
<field name="python_code">result = contract.wage * 0.02</field>
</record>
</odoo>
188 changes: 176 additions & 12 deletions payroll_contract_advantages/models/hr_contract_advantage.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.exceptions import UserError, ValidationError
from odoo.tools.safe_eval import safe_eval


class HrContractAdvantage(models.Model):
Expand All @@ -21,22 +22,185 @@ class HrContractAdvantage(models.Model):
advantage_upper_bound = fields.Float(
string="Upper Bound", related="advantage_template_id.upper_bound", readonly=True
)
amount = fields.Float()

# Definition copied from the template on selection, then editable
# per contract. Default "fixed" + the stored amount reproduces the
# historical behaviour.
computation_mode = fields.Selection(
selection=[
("fixed", "Fixed value"),
("percentage", "Percentage of a contract field"),
("python", "Python code"),
],
default="fixed",
required=True,
)
percentage = fields.Float(help="Percentage applied to the base field.")
percentage_base = fields.Char(
string="Percentage Base Field",
help="Name of a numeric hr.contract field used as base "
"(e.g. 'wage'). Unknown fields evaluate to 0.",
)
python_code = fields.Text(
help="Python code; assign the amount to 'result'. "
"Available variables are listed in the field.",
)
amount = fields.Float(
help="Latest evaluated amount, " "recomputed per payslip for non-fixed modes."
)

@api.onchange("advantage_template_id")
def _onchange_advantage_template_id(self):
"""Copy the template definition onto the advantage.

In 'fixed' mode ``amount`` keeps coming from the template
default value (historical behaviour). In 'percentage'/'python'
mode it is previewed from the actual computation so the user
does not see 0 while configuring; bounds are still enforced by
the ``amount`` constraint on save. Payslips recompute it live.
"""
for record in self:
record.amount = record.advantage_template_id.default_value
template = record.advantage_template_id
if not template:
continue
record.computation_mode = template.computation_mode
record.percentage = template.percentage
record.percentage_base = template.percentage_base
record.python_code = template.python_code
if record.computation_mode == "fixed":
record.amount = template.default_value
else:
preview, warning = record._preview_unit_value()
record.amount = preview
if warning:
return warning

@api.onchange(
"computation_mode",
"percentage",
"percentage_base",
"python_code",
)
def _onchange_computation_preview(self):
"""Preview ``amount`` while configuring the line.

Only in 'percentage'/'python' mode; 'fixed' keeps the
user-entered amount. Bounds are enforced by the ``amount``
constraint on save and payslips recompute it live.
"""
for record in self:
if record.computation_mode and record.computation_mode != "fixed":
preview, warning = record._preview_unit_value()
record.amount = preview
if warning:
return warning

def _preview_unit_value(self):
"""Tolerant unit value for the configuration screen.

Returns (value, warning). On any evaluation error the value is
0.0 and a non-blocking warning carries the exception text, so
the user can keep configuring without a traceback dialog. The
payslip computation stays strict.
"""
self.ensure_one()
try:
return self._compute_unit_value(), None
except Exception as err:
warning = {
"warning": {
"title": _("Preview unavailable"),
"message": _(
"The amount could not be evaluated; set to 0. "
"It will be recomputed on the payslip.\n\n%s"
)
% err,
}
}
return 0.0, warning

def _compute_advantage_amount(self, payslip=None):
"""Return the computed amount, bounded. Evaluated per payslip.

:param payslip: optional hr.payslip, exposed to python formulas.
"""
self.ensure_one()
value = self._compute_unit_value(payslip=payslip)
self._check_bounds(value)
return value

def _compute_unit_value(self, payslip=None):
"""Unit value per the computation mode."""
self.ensure_one()
contract = self.contract_id
mode = self.computation_mode or "fixed"

if mode == "fixed":
# Backward compatibility: the amount is typed directly on
# the advantage, so existing flows/records are unaffected.
value = self.amount
elif mode == "percentage":
base_field = (self.percentage_base or "").strip()
base_value = 0.0
if base_field and contract:
base_value = contract[base_field] if base_field in contract else 0.0
value = (base_value or 0.0) * (self.percentage or 0.0) / 100.0
elif mode == "python":
value = self._eval_code(self.python_code, payslip=payslip)
else:
value = 0.0

return self._coerce_float(value, _("unit value"))

def _coerce_float(self, value, label):
"""Float guarantee, mirroring hr.salary.rule._compute_rule."""
try:
return float(value)
except (TypeError, ValueError) as err:
raise UserError(
_(
"The computed %(label)s of advantage "
"'%(advantage)s' must be a float."
)
% {
"label": label,
"advantage": self.advantage_template_id.name
or self.advantage_template_code
or self.id,
}
) from err

def _eval_code(self, code, payslip=None):
"""Safely evaluate a generic python expression."""
self.ensure_one()
if not code:
return 0.0
localdict = {
"advantage": self,
"contract": self.contract_id,
"employee": self.contract_id.employee_id
if self.contract_id
else self.env["hr.employee"],
"payslip": payslip,
"result": 0.0,
}
safe_eval(code, localdict, mode="exec", nocopy=True)
return localdict.get("result", 0.0) or 0.0

def _check_bounds(self, value):
"""Enforce template lower/upper bounds on a candidate amount."""
self.ensure_one()
if value and value != 0.00:
if self.advantage_upper_bound and value > self.advantage_upper_bound:
raise ValidationError(
_("Advantage amount can't be greater than upper bound limit.")
)
elif self.advantage_lower_bound and value < self.advantage_lower_bound:
raise ValidationError(
_("Advantage amount can't be less than lower bound limit.")
)

@api.constrains("amount")
def _check_bound_limits(self):
for record in self:
if record.amount and record.amount != 0.00:
if record.amount > record.advantage_upper_bound:
raise ValidationError(
_("Advantage amount can't be greater than upper bound limit.")
)
elif record.amount < record.advantage_lower_bound:
raise ValidationError(
_("Advantage amount can't be less than lower bound limit.")
)
record._check_bounds(record.amount)
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,45 @@

from odoo import fields, models

DEFAULT_PYTHON_CODE = """# Available variables:
# - advantage: the hr.contract.advantage record
# - contract: the related hr.contract record
# - employee: the related hr.employee record
# - payslip: the hr.payslip being computed (None outside a payslip)
# Assign the amount to: result
# E.g. result = contract.wage * 0.05\n\n\n"""


class HrContractAdvandageTemplate(models.Model):
_name = "hr.contract.advantage.template"
_description = "Employee's Advantage on Contract"

name = fields.Char(required=True)
code = fields.Char(required=True)
lower_bound = fields.Float(
help="Lower bound authorized by the employer for this advantage"
lower_bound = fields.Float(help="Lower bound authorized for this advantage")
upper_bound = fields.Float(help="Upper bound authorized for this advantage")
default_value = fields.Float()

# Default "fixed" keeps the historical behaviour (amount =
# default_value), so existing databases are unaffected.
computation_mode = fields.Selection(
selection=[
("fixed", "Fixed value"),
("percentage", "Percentage of a contract field"),
("python", "Python code"),
],
default="fixed",
required=True,
help="How the unit value is computed.",
)
upper_bound = fields.Float(
help="Upper bound authorized by the employer for this advantage"
percentage = fields.Float(help="Percentage applied to the base field.")
percentage_base = fields.Char(
string="Percentage Base Field",
help="Name of a numeric hr.contract field used as base "
"(e.g. 'wage'). Unknown fields evaluate to 0.",
)
python_code = fields.Text(
default=DEFAULT_PYTHON_CODE,
help="Python code; assign the amount to 'result'. "
"Available variables are listed in the field.",
)
default_value = fields.Float()
13 changes: 12 additions & 1 deletion payroll_contract_advantages/models/hr_payslip.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,22 @@ class HrPayslip(models.Model):
_inherit = "hr.payslip"

def get_current_contract_dict(self, contract, contracts):
"""Expose advantages by code in the salary rules localdict.

Amounts are (re)evaluated per payslip from each advantage's
formula, so period-sensitive values stay correct. In 'fixed'
mode the value equals the stored amount, unchanged for existing
installations.
"""
self.ensure_one()
res = super().get_current_contract_dict(contract, contracts)
advantages_dict = {}
for advantage in contract.advantages_ids:
advantages_dict[advantage.advantage_template_code] = advantage.amount
amount = advantage._compute_advantage_amount(payslip=self)
# Keep the stored amount in sync for reporting / auditing.
if advantage.amount != amount:
advantage.amount = amount
advantages_dict[advantage.advantage_template_code] = amount
res.update(
{"advantages": BrowsableObject(self.employee_id, advantages_dict, self.env)}
)
Expand Down
5 changes: 3 additions & 2 deletions payroll_contract_advantages/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
- Nimarosa (Nicolas Rodriguez) \<nicolasrsande@gmail.com\>
- Cristiano Mafra Junior \<cristiano.mafra@escodoo.com.br\>
- Nimarosa (Nicolas Rodriguez) \<<nicolasrsande@gmail.com>\>
- Cristiano Mafra Junior \<<cristiano.mafra@escodoo.com.br>\>
- Cyril VINH-TUNG \<<cyril@invitu.com>\>
Loading
Loading