Skip to content
Closed
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
38 changes: 23 additions & 15 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 @@ -32,10 +28,14 @@ Payroll Contract Advantages

|badge1| |badge2| |badge3| |badge4| |badge5|

This module adds support for advantages templates and advantages to be
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.
This module lets you define advantage templates and set advantages on
the contract form, for use in salary rule computation.

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

**Table of contents**

Expand All @@ -45,12 +45,20 @@ of the salary rules.
Usage
=====

- Set the advantages templates in the payroll module with lower and
upper bounds and default value.
- Go to the employee contract and add the advantages that you want for
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)
- Create advantage templates with lower/upper bounds, a computation mode
(fixed value, percentage of a contract field, or Python code) and a
quantity mode (fixed or Python code).
- Add advantages on the employee contract. The definition is copied from
the template and can be tuned per contract.
- The amount is **quantity x unit value**, re-evaluated for each
payslip. Python formulas expose ``advantage``, ``contract``,
``employee``, ``payslip`` and must set ``result``. The ``Quantity``
field is also a free parameter readable via
``advantage.quantity_fixed_value``.
- Bounds are enforced on the final amount; a non-numeric formula result
raises an error.
- In salary rules, read the value with
``current_contract.advantages.[ADVANTAGE_CODE]``.

Bug Tracker
===========
Expand Down
2 changes: 1 addition & 1 deletion payroll_contract_advantages/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

{
"name": "Payroll Contract Advantages",
"version": "18.0.1.0.0",
"version": "18.0.2.0.0",
"category": "Payroll",
"website": "https://github.com/OCA/payroll",
"summary": "Allow to define contract advantages for employees.",
Expand Down
45 changes: 45 additions & 0 deletions payroll_contract_advantages/migrations/18.0.2.0.0/pre-migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
"""Init new fields so existing records keep the historical behaviour."""


def migrate(cr, version):
if not version:
return
# Backfill fixed_value from the historical amount.
cr.execute(
"""
UPDATE hr_contract_advantage
SET fixed_value = amount
WHERE fixed_value IS NULL OR fixed_value = 0.0
"""
)
# Make computation_mode explicit (cover any leftover NULL).
cr.execute(
"""
UPDATE hr_contract_advantage
SET computation_mode = 'fixed'
WHERE computation_mode IS NULL
"""
)
cr.execute(
"""
UPDATE hr_contract_advantage_template
SET computation_mode = 'fixed'
WHERE computation_mode IS NULL
"""
)
# Quantity model: default fixed 1.0 -> amount = unit value.
cr.execute(
"""
UPDATE hr_contract_advantage
SET quantity_mode = 'fixed'
WHERE quantity_mode IS NULL
"""
)
cr.execute(
"""
UPDATE hr_contract_advantage_template
SET quantity_fixed_value = 1.0
WHERE quantity_fixed_value IS NULL OR quantity_fixed_value = 0.0
"""
)
179 changes: 167 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,176 @@ 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" + fixed_value reproduces the
# historical amount behaviour (see migration).
computation_mode = fields.Selection(
selection=[
("fixed", "Fixed value"),
("percentage", "Percentage of a contract field"),
("python", "Python code"),
],
default="fixed",
required=True,
)
fixed_value = fields.Float(help="Value used in 'Fixed value' mode.")
percentage = fields.Float(help="Percentage applied to the base field.")
percentage_base = fields.Char(
string="Percentage Base Field",
help="Numeric hr.contract field used as base (e.g. 'wage').",
)
python_code = fields.Text(
help="Expression with: advantage, contract, employee, payslip. "
"Set 'result'.",
)
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="Expression with: advantage, contract, employee, payslip. "
"Set 'result'.",
)
amount = fields.Float(
help="Latest evaluated amount (quantity x unit value), "
"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.

``amount`` is still populated from the template default value so
existing flows and the 'fixed' mode behave as before.
"""
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.fixed_value = template.default_value
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
record.amount = template.default_value

def _compute_advantage_amount(self, payslip=None):
"""Return quantity x unit value, bounded. Evaluated per payslip.

: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 = quantity * unit_value
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: historically the amount was typed
# directly on the advantage (no fixed_value field). If
# fixed_value was never set but amount was, keep using
# amount so existing flows/records are unaffected.
if not self.fixed_value and self.amount:
value = self.amount
else:
value = self.fixed_value
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"))

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 @@ -9,10 +9,49 @@ class HrContractAdvandageTemplate(models.Model):

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="Numeric hr.contract field used as base (e.g. 'wage').",
)
python_code = fields.Text(
help="Expression with: advantage, contract, employee, payslip. "
"Set 'result'. E.g. result = contract.wage * 0.05",
)

# Final amount = quantity * unit value. Default fixed quantity 1.0
# keeps the historical behaviour (amount = unit value).
quantity_mode = fields.Selection(
selection=[
("fixed", "Fixed quantity"),
("python", "Python code"),
],
default="fixed",
required=True,
help="How the quantity is computed.",
)
quantity_fixed_value = fields.Float(
string="Quantity",
default=1.0,
help="Quantity used in 'Fixed quantity' mode.",
)
quantity_python_code = fields.Text(
help="Expression with: advantage, contract, employee, payslip. "
"Set 'result'.",
)
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 fixed_value, 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
Loading
Loading