Skip to content
Draft
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
24 changes: 19 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,13 @@ 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) giving a unit value, and a
quantity mode (fixed or Python). The final amount is the unit value
times the quantity, re-evaluated per payslip. The default fixed mode and
quantity 1.0 reproduce the historical behaviour. Template bounds are
enforced on the final amount.

**Table of contents**

.. contents::
Expand All @@ -51,6 +54,16 @@ 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) and a quantity mode (fixed or Python
code). The definition is copied onto the advantage and can be tuned
per contract.
- ``amount`` holds the unit value; the final amount is the unit value
times the quantity, re-evaluated for each payslip. Python formulas
expose ``advantage``, ``contract``, ``employee``, ``payslip`` and must
set ``result``.
- Bounds are enforced on the final amount; a non-numeric formula result
raises an error.

Bug Tracker
===========
Expand All @@ -75,6 +88,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,68 @@
<?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>
<!-- Quantity-driven template: km mileage (unit rate x monthly km) -->
<record id="advantage_template_mileage" model="hr.contract.advantage.template">
<field name="name">Mileage Allowance</field>
<field name="code">KM</field>
<field name="lower_bound">0.0</field>
<field name="upper_bound">2000.0</field>
<field name="computation_mode">fixed</field>
<field name="default_value">0.5</field>
<field name="quantity_mode">fixed</field>
<field name="quantity_fixed_value">300.0</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>
<record id="advantage_mileage_admin" model="hr.contract.advantage">
<field name="contract_id" ref="hr_contract.hr_contract_admin_new" />
<field name="advantage_template_id" ref="advantage_template_mileage" />
<field name="computation_mode">fixed</field>
<field name="amount">0.5</field>
<field name="quantity_mode">fixed</field>
<field name="quantity_fixed_value">300.0</field>
</record>
</odoo>
249 changes: 236 additions & 13 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,244 @@ 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.",
)
quantity_mode = fields.Selection(
selection=[
("fixed", "Fixed quantity"),
("python", "Python code"),
],
default="fixed",
required=True,
)
quantity_fixed_value = fields.Float(
string="Quantity",
default=1.0,
help="Quantity used in 'Fixed quantity' mode.",
)
quantity_python_code = fields.Text(
help="Python code; assign the quantity to 'result'. "
"Available variables are listed in the field.",
)
amount = fields.Float(
string="Unit amount",
help="Unit value. Recomputed per payslip for non-fixed modes; "
"the final amount is this value times the quantity.",
)
quantity_final = fields.Float(
string="Computed Quantity",
compute="_compute_quantity_final",
help="Quantity actually applied (preview; recomputed on the "
"payslip for period-sensitive formulas).",
)

@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
record.quantity_mode = template.quantity_mode
record.quantity_fixed_value = template.quantity_fixed_value
record.quantity_python_code = template.quantity_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.

@api.constrains("amount")
def _check_bound_limits(self):
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.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.")
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 final amount (unit value x quantity), bounded.

Evaluated per payslip. Bounds are enforced on the final amount.

:param payslip: optional hr.payslip, exposed to python formulas.
"""
self.ensure_one()
unit_value = self._compute_unit_value(payslip=payslip)
quantity = self._compute_quantity(payslip=payslip)
amount = unit_value * quantity
self._check_bounds(amount)
return amount

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 _compute_quantity(self, payslip=None):
"""Quantity per the quantity mode. Default fixed 1.0."""
self.ensure_one()
mode = self.quantity_mode or "fixed"
if mode == "python":
value = self._eval_code(self.quantity_python_code, payslip=payslip)
else:
# An explicit 0 quantity is valid (amount 0).
value = (
self.quantity_fixed_value
if self.quantity_fixed_value is not False
else 1.0
)
return self._coerce_float(value, _("quantity"))

@api.depends(
"quantity_mode",
"quantity_fixed_value",
"quantity_python_code",
)
def _compute_quantity_final(self):
"""Tolerant quantity preview for lists/forms.

Never raises so the list always renders; the payslip recomputes
it strictly with the period context.
"""
for record in self:
try:
record.quantity_final = record._compute_quantity()
except Exception:
record.quantity_final = 0.0

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 bounds on the final amount (unit x qty)."""
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.")
)
Loading
Loading