diff --git a/payroll_contract_advantages/README.rst b/payroll_contract_advantages/README.rst index 29bf2e97c..c37237026 100644 --- a/payroll_contract_advantages/README.rst +++ b/payroll_contract_advantages/README.rst @@ -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 =========================== @@ -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 @@ -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:: @@ -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 =========== @@ -75,6 +88,7 @@ Contributors - Nimarosa (Nicolas Rodriguez) - Cristiano Mafra Junior +- Cyril VINH-TUNG Maintainers ----------- diff --git a/payroll_contract_advantages/__manifest__.py b/payroll_contract_advantages/__manifest__.py index f8351fdbb..518b5297f 100644 --- a/payroll_contract_advantages/__manifest__.py +++ b/payroll_contract_advantages/__manifest__.py @@ -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"], } diff --git a/payroll_contract_advantages/demo/payroll_contract_advantages_demo.xml b/payroll_contract_advantages/demo/payroll_contract_advantages_demo.xml new file mode 100644 index 000000000..e865df42a --- /dev/null +++ b/payroll_contract_advantages/demo/payroll_contract_advantages_demo.xml @@ -0,0 +1,68 @@ + + + + + Meal Voucher + MEAL + 0.0 + 500.0 + 10.0 + fixed + + + Housing Allowance + HOUS + 0.0 + 5000.0 + percentage + 5.0 + wage + + + Phone Allowance + PHONE + 0.0 + 1000.0 + python + result = contract.wage * 0.02 + + + + Mileage Allowance + KM + 0.0 + 2000.0 + fixed + 0.5 + fixed + 300.0 + + + + + + fixed + 10.0 + + + + + percentage + 5.0 + wage + + + + + python + result = contract.wage * 0.02 + + + + + fixed + 0.5 + fixed + 300.0 + + diff --git a/payroll_contract_advantages/models/hr_contract_advantage.py b/payroll_contract_advantages/models/hr_contract_advantage.py index ea3cd0b94..cba78495e 100644 --- a/payroll_contract_advantages/models/hr_contract_advantage.py +++ b/payroll_contract_advantages/models/hr_contract_advantage.py @@ -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): @@ -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.") + ) diff --git a/payroll_contract_advantages/models/hr_contract_advantage_template.py b/payroll_contract_advantages/models/hr_contract_advantage_template.py index d3db791b0..d2de752f3 100644 --- a/payroll_contract_advantages/models/hr_contract_advantage_template.py +++ b/payroll_contract_advantages/models/hr_contract_advantage_template.py @@ -2,6 +2,23 @@ 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""" + +DEFAULT_QUANTITY_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 quantity to: result +# E.g. sum the worked days of the current payslip: +# result = sum(payslip.worked_days_line_ids.mapped("number_of_days"))\n\n\n""" + class HrContractAdvandageTemplate(models.Model): _name = "hr.contract.advantage.template" @@ -10,9 +27,55 @@ 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" + help="Lower bound enforced on the final amount " "(unit value x quantity)." ) upper_bound = fields.Float( - help="Upper bound authorized by the employer for this advantage" + help="Upper bound enforced on the final amount " "(unit value x quantity)." ) 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.", + ) + 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.", + ) + + # 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( + default=DEFAULT_QUANTITY_PYTHON_CODE, + help="Python code; assign the quantity to 'result'. " + "Available variables are listed in the field.", + ) diff --git a/payroll_contract_advantages/models/hr_payslip.py b/payroll_contract_advantages/models/hr_payslip.py index 9d4170cbb..888518b97 100644 --- a/payroll_contract_advantages/models/hr_payslip.py +++ b/payroll_contract_advantages/models/hr_payslip.py @@ -9,11 +9,18 @@ class HrPayslip(models.Model): _inherit = "hr.payslip" def get_current_contract_dict(self, contract, contracts): + """Expose advantages by code in the salary rules localdict. + + The exposed value is the final amount (unit value x quantity), + (re)evaluated per payslip so period-sensitive formulas stay + correct. ``amount`` stays the unit value and is not overwritten. + """ 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) + advantages_dict[advantage.advantage_template_code] = amount res.update( {"advantages": BrowsableObject(self.employee_id, advantages_dict, self.env)} ) diff --git a/payroll_contract_advantages/readme/CONTRIBUTORS.md b/payroll_contract_advantages/readme/CONTRIBUTORS.md index 292dd54d3..15e640623 100644 --- a/payroll_contract_advantages/readme/CONTRIBUTORS.md +++ b/payroll_contract_advantages/readme/CONTRIBUTORS.md @@ -1,2 +1,3 @@ -- Nimarosa (Nicolas Rodriguez) \ -- Cristiano Mafra Junior \ +- Nimarosa (Nicolas Rodriguez) \<\> +- Cristiano Mafra Junior \<\> +- Cyril VINH-TUNG \<\> diff --git a/payroll_contract_advantages/readme/DESCRIPTION.md b/payroll_contract_advantages/readme/DESCRIPTION.md index 21305a80b..e15056508 100644 --- a/payroll_contract_advantages/readme/DESCRIPTION.md +++ b/payroll_contract_advantages/readme/DESCRIPTION.md @@ -2,3 +2,10 @@ 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. + +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. diff --git a/payroll_contract_advantages/readme/USAGE.md b/payroll_contract_advantages/readme/USAGE.md index 0dc71a47c..68e85a9e1 100644 --- a/payroll_contract_advantages/readme/USAGE.md +++ b/payroll_contract_advantages/readme/USAGE.md @@ -4,3 +4,13 @@ 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. diff --git a/payroll_contract_advantages/static/description/index.html b/payroll_contract_advantages/static/description/index.html index c0e8e1bf6..fe16095c7 100644 --- a/payroll_contract_advantages/static/description/index.html +++ b/payroll_contract_advantages/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Payroll Contract Advantages -
+
+

Payroll Contract Advantages

- - -Odoo Community Association - -
-

Payroll Contract Advantages

-

Beta License: LGPL-3 OCA/payroll Translate me on Weblate Try me on Runboat

+

Beta License: LGPL-3 OCA/payroll Translate me on Weblate Try me on Runboat

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.

+

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

    @@ -393,7 +394,7 @@

    Payroll Contract Advantages

-

Usage

+

Usage

  • Set the advantages templates in the payroll module with lower and upper bounds and default value.
  • @@ -401,10 +402,20 @@

    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

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -412,22 +423,23 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Nimarosa
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -442,6 +454,5 @@

Maintainers

-
diff --git a/payroll_contract_advantages/tests/test_payroll_contract_advantages.py b/payroll_contract_advantages/tests/test_payroll_contract_advantages.py index 86013c95c..ffff937bf 100644 --- a/payroll_contract_advantages/tests/test_payroll_contract_advantages.py +++ b/payroll_contract_advantages/tests/test_payroll_contract_advantages.py @@ -1,7 +1,7 @@ # Copyright 2025 - TODAY, Cristiano Mafra Junior # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo.exceptions import ValidationError +from odoo.exceptions import UserError, ValidationError from odoo.addons.payroll.tests.common import TestPayslipBase @@ -60,30 +60,32 @@ def test_constraint_allows_amount_inside_bounds(self): self.assertEqual(advantage.amount, 50.0) def test_constraint_raises_if_above_upper_bound(self): - """Constraint should raise when amount is above upper bound.""" + """Bounds are enforced on the final amount.""" template = self._create_template(lower=0.0, upper=100.0) + advantage = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "amount": 150.0, + } + ) with self.assertRaises(ValidationError): - self.Advantage.create( - { - "contract_id": self.richard_contract.id, - "advantage_template_id": template.id, - "amount": 150.0, - } - ) + advantage._compute_advantage_amount() def test_constraint_raises_if_below_lower_bound(self): - """Constraint should raise when amount is below lower bound.""" + """Bounds are enforced on the final amount.""" template = self._create_template(lower=10.0, upper=100.0) + advantage = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "amount": 5.0, + } + ) with self.assertRaises(ValidationError): - self.Advantage.create( - { - "contract_id": self.richard_contract.id, - "advantage_template_id": template.id, - "amount": 5.0, - } - ) + advantage._compute_advantage_amount() def test_get_current_contract_dict_contains_advantages(self): """get_current_contract_dict should expose advantages by code.""" @@ -114,3 +116,349 @@ def test_get_current_contract_dict_contains_advantages(self): self.assertIsNotNone(advantages) self.assertEqual(advantages.FUEL, 30.0) + + # ------------------------------------------------------------------ + # Computation modes + # ------------------------------------------------------------------ + + def test_default_mode_is_fixed_backward_compatible(self): + """A template created the historical way defaults to 'fixed'.""" + template = self._create_template(default=99.0) + self.assertEqual(template.computation_mode, "fixed") + + advantage = self.Advantage.new( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + } + ) + advantage._onchange_advantage_template_id() + self.assertEqual(advantage.computation_mode, "fixed") + # Historical 'amount' still populated from default value. + self.assertEqual(advantage.amount, 99.0) + + def test_fixed_mode_amount_equals_stored_amount(self): + template = self._create_template(default=150.0, upper=1000000.0) + advantage = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "computation_mode": "fixed", + "amount": 150.0, + } + ) + self.assertEqual(advantage._compute_advantage_amount(), 150.0) + + def test_percentage_mode_uses_contract_field(self): + template = self._create_template( + lower=0.0, upper=1000000.0, code="HOUSING", name="Housing" + ) + template.computation_mode = "percentage" + template.percentage = 5.0 + template.percentage_base = "wage" + + advantage = self.Advantage.new( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + } + ) + advantage._onchange_advantage_template_id() + self.assertEqual(advantage.computation_mode, "percentage") + self.assertEqual(advantage.percentage, 5.0) + self.assertEqual(advantage.percentage_base, "wage") + + expected = self.richard_contract.wage * 5.0 / 100.0 + self.assertAlmostEqual(advantage._compute_advantage_amount(), expected) + + def test_python_mode_evaluates_formula(self): + template = self._create_template( + lower=0.0, upper=1000000.0, code="PY", name="Py" + ) + template.computation_mode = "python" + template.python_code = "result = contract.wage * 0.10" + + advantage = self.Advantage.new( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + } + ) + advantage._onchange_advantage_template_id() + expected = self.richard_contract.wage * 0.10 + self.assertAlmostEqual(advantage._compute_advantage_amount(), expected) + + def test_python_mode_receives_payslip_in_localdict(self): + """The python localdict must expose payslip (needed by + period-sensitive localisation formulas).""" + template = self._create_template( + lower=0.0, upper=1000000.0, code="PYPS", name="PyPayslip" + ) + template.computation_mode = "python" + # If payslip is exposed and not None, result is the wage, + # otherwise 0 -> asserts the name is present in the localdict. + template.python_code = "result = contract.wage if payslip is not None else 0.0" + advantage = self.Advantage.new( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + } + ) + advantage._onchange_advantage_template_id() + + self.apply_contract_cron() + payslip = self.Payslip.create({"employee_id": self.richard_emp.id}) + payslip.onchange_employee() + amount = advantage._compute_advantage_amount(payslip=payslip) + self.assertAlmostEqual(amount, self.richard_contract.wage) + + def test_bounds_enforced_on_computed_amount(self): + """Bounds must be checked on the evaluated amount, not only on + a manually typed one.""" + template = self._create_template( + lower=0.0, upper=100.0, code="CAP", name="Capped" + ) + template.computation_mode = "python" + template.python_code = "result = 500.0" # above upper bound + + advantage = self.Advantage.new( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + } + ) + advantage._onchange_advantage_template_id() + with self.assertRaises(ValidationError): + advantage._compute_advantage_amount() + + def test_get_current_contract_dict_evaluates_percentage(self): + """End to end: the value exposed to salary rules is the + evaluated formula, recomputed at payslip time.""" + template = self._create_template( + lower=0.0, upper=1000000.0, code="PCT", name="Pct" + ) + template.computation_mode = "percentage" + template.percentage = 10.0 + template.percentage_base = "wage" + + self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "computation_mode": "percentage", + "percentage": 10.0, + "percentage_base": "wage", + } + ) + + self.apply_contract_cron() + payslip = self.Payslip.create({"employee_id": self.richard_emp.id}) + payslip.onchange_employee() + contracts = payslip._get_employee_contracts() + res = payslip.get_current_contract_dict(self.richard_contract, contracts) + expected = self.richard_contract.wage * 10.0 / 100.0 + self.assertAlmostEqual(res.get("advantages").PCT, expected) + + def test_python_returning_non_float_raises_usererror(self): + """A python_code formula that yields a non-numeric value must + fail loudly (float guarantee mirrored from the payroll engine), + not silently corrupt the payslip.""" + template = self._create_template( + lower=0.0, upper=1000000.0, code="BAD", name="BadFormula" + ) + template.computation_mode = "python" + template.python_code = "result = 'not-a-number'" + + advantage = self.Advantage.new( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + } + ) + advantage._onchange_advantage_template_id() + with self.assertRaises(UserError): + advantage._compute_advantage_amount() + + def test_python_returning_int_is_coerced_to_float(self): + """An int result is acceptable and coerced to float (no error).""" + template = self._create_template( + lower=0.0, upper=1000000.0, code="INTOK", name="IntOk" + ) + template.computation_mode = "python" + template.python_code = "result = 1500" + + advantage = self.Advantage.new( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + } + ) + advantage._onchange_advantage_template_id() + amount = advantage._compute_advantage_amount() + self.assertEqual(amount, 1500.0) + self.assertIsInstance(amount, float) + + # ------------------------------------------------------------------ + # Quantity (final amount = quantity x unit value) + # ------------------------------------------------------------------ + + def test_quantity_defaults_keep_backward_compat(self): + """Default quantity mode 'fixed' / value 1.0 -> amount equals + the unit value (historical behaviour).""" + template = self._create_template(default=200.0, upper=1000000.0) + adv = self.Advantage.new( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + } + ) + adv._onchange_advantage_template_id() + self.assertEqual(adv.quantity_mode, "fixed") + self.assertEqual(adv.quantity_fixed_value, 1.0) + self.assertEqual(adv._compute_advantage_amount(), 200.0) + + def test_fixed_quantity_multiplies_unit_value(self): + template = self._create_template( + lower=0.0, upper=1000000.0, code="QF", name="QtyFixed" + ) + adv = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "computation_mode": "fixed", + "amount": 410.0, + "quantity_mode": "fixed", + "quantity_fixed_value": 22.0, + } + ) + self.assertAlmostEqual(adv._compute_advantage_amount(), 22.0 * 410.0) + + def test_python_quantity_times_python_unit_value(self): + """Both quantity and unit value computed by python.""" + template = self._create_template( + lower=0.0, upper=1000000.0, code="QP", name="QtyPy" + ) + adv = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "computation_mode": "python", + "python_code": "result = 1000.0", + "quantity_mode": "python", + "quantity_python_code": "result = 3", + } + ) + self.assertAlmostEqual(adv._compute_advantage_amount(), 3 * 1000.0) + + def test_quantity_final_is_tolerant(self): + """quantity_final never raises (list rendering safety); a bad + quantity formula previews 0.""" + template = self._create_template( + lower=0.0, upper=1000000.0, code="QBAD", name="QtyBad" + ) + adv = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "computation_mode": "fixed", + "amount": 100.0, + "quantity_mode": "python", + "quantity_python_code": "result = undefined_name", + } + ) + self.assertEqual(adv.quantity_final, 0.0) + + def test_amount_not_overwritten_by_payslip(self): + """A-1: amount stays the unit value; the payslip exposes + unit x quantity without overwriting amount.""" + template = self._create_template( + lower=0.0, upper=1000000.0, code="QSTAB", name="QtyStable" + ) + adv = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "computation_mode": "fixed", + "amount": 50.0, + "quantity_mode": "fixed", + "quantity_fixed_value": 4.0, + } + ) + self.apply_contract_cron() + payslip = self.Payslip.create({"employee_id": self.richard_emp.id}) + payslip.onchange_employee() + contracts = payslip._get_employee_contracts() + res = payslip.get_current_contract_dict(self.richard_contract, contracts) + self.assertAlmostEqual(res.get("advantages").QSTAB, 4.0 * 50.0) + # amount must remain the unit value (no feedback corruption). + self.assertEqual(adv.amount, 50.0) + + def test_bounds_apply_to_product_not_unit_value(self): + """Choix 1: bounds are enforced on the final amount + (unit value x quantity), not on the unit value alone.""" + template = self._create_template( + lower=0.0, upper=100.0, code="QCAP", name="QtyCapped" + ) + adv = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "computation_mode": "fixed", + "amount": 30.0, # unit value inside [0, 100] + "quantity_mode": "fixed", + "quantity_fixed_value": 1.0, + } + ) + # Unit value alone is within bounds -> no error. + self.assertEqual(adv._compute_advantage_amount(), 30.0) + + # Same unit value but quantity pushes the product over the + # upper bound -> must raise (bounds are on the product). + adv.quantity_fixed_value = 5.0 # 30 x 5 = 150 > 100 + with self.assertRaises(ValidationError): + adv._compute_advantage_amount() + + def test_quantity_final_nominal_value(self): + """quantity_final exposes the computed quantity (success path) + for both fixed and python quantity modes.""" + template = self._create_template( + lower=0.0, upper=1000000.0, code="QFIN", name="QtyFinal" + ) + adv = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "computation_mode": "fixed", + "amount": 100.0, + "quantity_mode": "fixed", + "quantity_fixed_value": 7.0, + } + ) + self.assertEqual(adv.quantity_final, 7.0) + + adv.quantity_mode = "python" + adv.quantity_python_code = "result = 9" + adv.invalidate_recordset(["quantity_final"]) + self.assertEqual(adv.quantity_final, 9.0) + + def test_python_quantity_receives_payslip_in_localdict(self): + """The quantity python localdict must expose payslip (needed by + period-sensitive formulas), mirrored from the unit value side.""" + template = self._create_template( + lower=0.0, upper=1000000.0, code="QPS", name="QtyPayslip" + ) + adv = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "computation_mode": "fixed", + "amount": 50.0, + "quantity_mode": "python", + "quantity_python_code": ("result = 4 if payslip is not None else 0"), + } + ) + self.apply_contract_cron() + payslip = self.Payslip.create({"employee_id": self.richard_emp.id}) + payslip.onchange_employee() + amount = adv._compute_advantage_amount(payslip=payslip) + self.assertAlmostEqual(amount, 4 * 50.0) diff --git a/payroll_contract_advantages/views/hr_contract_advantage_views.xml b/payroll_contract_advantages/views/hr_contract_advantage_views.xml index b58eb7bd7..da876f51d 100644 --- a/payroll_contract_advantages/views/hr_contract_advantage_views.xml +++ b/payroll_contract_advantages/views/hr_contract_advantage_views.xml @@ -17,11 +17,42 @@ - - + + + + + + + + + + + + + @@ -34,6 +65,8 @@ + + diff --git a/payroll_contract_advantages/views/hr_contract_views.xml b/payroll_contract_advantages/views/hr_contract_views.xml index dfe5643b4..742f6c0d0 100644 --- a/payroll_contract_advantages/views/hr_contract_views.xml +++ b/payroll_contract_advantages/views/hr_contract_views.xml @@ -8,16 +8,64 @@ - + - - + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
diff --git a/payroll_contract_advantages_base/README.rst b/payroll_contract_advantages_base/README.rst new file mode 100644 index 000000000..930daea1e --- /dev/null +++ b/payroll_contract_advantages_base/README.rst @@ -0,0 +1,159 @@ +================================ +Payroll Contract Advantages Base +================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:90688f1aaaa4fb6d6882006a7d2999d66f3469b3b5f5418d18a3da79747f9526 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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 + :target: https://github.com/OCA/payroll/tree/18.0/payroll_contract_advantages_base + :alt: OCA/payroll +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/payroll-18-0/payroll-18-0-payroll_contract_advantages_base + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/payroll&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Provides an abstract sync mechanism that creates a +``hr.contract.advantage`` from each applicable +``hr.contract.advantage.template`` on a contract. The mechanism is +additive only: existing advantages on the contract are never removed nor +overwritten. + +This base module does not select any template by itself. It exposes a +hook to be plugged by bridge modules, which can attach templates to the +criterion that fits their needs, for example: + +- by salary structure: an advantage applies to all employees on a given + salary structure (e.g. company car for executives); +- by employee tag (``hr.employee.category``): an advantage applies to + every employee tagged accordingly (e.g. meal voucher for on-site + staff); +- by department: an advantage applies to a whole department (e.g. IT + support stipend); +- by job position: an advantage applies to a specific position; +- by company, country, or any other criterion the integrator needs. + +Each criterion is implemented by its own small bridge module that +overrides the hook and provides the relevant trigger. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +This module is a technical base; install a bridge module (e.g. +``payroll_contract_advantages_structure``) to enable the synchronization +with a real criterion. + +For module developers, a bridge module needs to: + +1. attach the templates to its criterion (typically a Many2many); +2. override ``_get_applicable_advantage_templates`` on ``hr.contract`` + to return the templates resolved from that criterion; +3. call ``_sync_advantages_from_templates()`` from the trigger that + makes sense for the criterion (onchange, button, write override). + +Example: bridge module ``payroll_contract_advantages_structure`` +(criterion = salary structure): + +.. code:: python + + from odoo import api, fields, models + + + class HrContractAdvantageTemplate(models.Model): + _inherit = "hr.contract.advantage.template" + + structure_ids = fields.Many2many( + comodel_name="hr.payroll.structure", + relation="hr_contract_advantage_template_structure_rel", + column1="template_id", + column2="structure_id", + string="Salary Structures", + ) + + + class HrPayrollStructure(models.Model): + _inherit = "hr.payroll.structure" + + advantage_template_ids = fields.Many2many( + comodel_name="hr.contract.advantage.template", + relation="hr_contract_advantage_template_structure_rel", + column1="structure_id", + column2="template_id", + string="Advantage Templates", + ) + + + class HrContract(models.Model): + _inherit = "hr.contract" + + def _get_applicable_advantage_templates(self): + return self.struct_id.advantage_template_ids + + @api.onchange("struct_id") + def _onchange_struct_id_sync_advantages(self): + for contract in self: + contract._sync_advantages_from_templates() + +The same pattern applies to any other criterion (employee tags, +department, job position...): the bridge module declares the link, +overrides the hook and picks the right trigger. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* INVITU + +Contributors +------------ + +- Cyril VINH-TUNG + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/payroll `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/payroll_contract_advantages_base/__init__.py b/payroll_contract_advantages_base/__init__.py new file mode 100644 index 000000000..af378d5cf --- /dev/null +++ b/payroll_contract_advantages_base/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import models diff --git a/payroll_contract_advantages_base/__manifest__.py b/payroll_contract_advantages_base/__manifest__.py new file mode 100644 index 000000000..7d28ac87b --- /dev/null +++ b/payroll_contract_advantages_base/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": "Payroll Contract Advantages Base", + "version": "18.0.1.0.0", + "category": "Payroll", + "website": "https://github.com/OCA/payroll", + "summary": "Abstract sync of advantages from templates; bridge modules" + " plug the actual selection criterion.", + "license": "LGPL-3", + "author": "INVITU, Odoo Community Association (OCA)", + "depends": ["payroll_contract_advantages"], + "installable": True, +} diff --git a/payroll_contract_advantages_base/models/__init__.py b/payroll_contract_advantages_base/models/__init__.py new file mode 100644 index 000000000..3ebe6222e --- /dev/null +++ b/payroll_contract_advantages_base/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import hr_contract_advantage_abstract +from . import hr_contract diff --git a/payroll_contract_advantages_base/models/hr_contract.py b/payroll_contract_advantages_base/models/hr_contract.py new file mode 100644 index 000000000..502899ff7 --- /dev/null +++ b/payroll_contract_advantages_base/models/hr_contract.py @@ -0,0 +1,9 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import models + + +class HrContract(models.Model): + _name = "hr.contract" + _inherit = ["hr.contract.advantage.abstract", "hr.contract"] diff --git a/payroll_contract_advantages_base/models/hr_contract_advantage_abstract.py b/payroll_contract_advantages_base/models/hr_contract_advantage_abstract.py new file mode 100644 index 000000000..ce0eb1cdd --- /dev/null +++ b/payroll_contract_advantages_base/models/hr_contract_advantage_abstract.py @@ -0,0 +1,63 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import Command, models + +# Fields copied from the template onto a new advantage. Update here +# when a new field is added to the template/advantage definition. +_TEMPLATE_FIELDS = ( + "computation_mode", + "percentage", + "percentage_base", + "python_code", + "quantity_mode", + "quantity_fixed_value", + "quantity_python_code", +) + + +class HrContractAdvantageAbstract(models.AbstractModel): + _name = "hr.contract.advantage.abstract" + _description = "Sync advantages from applicable templates" + + def _get_applicable_advantage_templates(self): + """Templates that should yield an advantage on this contract. + + Returns an empty recordset by default. Bridge modules override + this to plug a selection criterion (e.g. salary structure). + """ + return self.env["hr.contract.advantage.template"] + + def _copy_template_fields(self, template): + """Build the vals copied from a template onto a new advantage. + + Bridge modules and downstream addons can extend this to + propagate extra fields they add. + """ + vals = {name: template[name] for name in _TEMPLATE_FIELDS} + # 'amount' (unit value) starts at the template default value; + # non-fixed modes recompute it on the payslip anyway. + vals["amount"] = template.default_value + return vals + + def _sync_advantages_from_templates(self): + """Add a hr.contract.advantage for each applicable template + not already attached. Additive only: never removes nor + overwrites existing lines. Works both in onchange (in-memory) + and from code (persisted on save), by assigning Commands to + the x2many field rather than calling create() directly. + """ + for contract in self: + existing = contract.advantages_ids.advantage_template_id + templates = contract._get_applicable_advantage_templates() - existing + if not templates: + continue + contract.advantages_ids = [ + Command.create( + { + **contract._copy_template_fields(template), + "advantage_template_id": template.id, + } + ) + for template in templates + ] diff --git a/payroll_contract_advantages_base/pyproject.toml b/payroll_contract_advantages_base/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/payroll_contract_advantages_base/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/payroll_contract_advantages_base/readme/CONTRIBUTORS.md b/payroll_contract_advantages_base/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..e552444b7 --- /dev/null +++ b/payroll_contract_advantages_base/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Cyril VINH-TUNG \<\> diff --git a/payroll_contract_advantages_base/readme/DESCRIPTION.md b/payroll_contract_advantages_base/readme/DESCRIPTION.md new file mode 100644 index 000000000..05cec8513 --- /dev/null +++ b/payroll_contract_advantages_base/readme/DESCRIPTION.md @@ -0,0 +1,22 @@ +Provides an abstract sync mechanism that creates a +``hr.contract.advantage`` from each applicable +``hr.contract.advantage.template`` on a contract. The mechanism is +additive only: existing advantages on the contract are never removed +nor overwritten. + +This base module does not select any template by itself. It exposes a +hook to be plugged by bridge modules, which can attach templates to +the criterion that fits their needs, for example: + +- by salary structure: an advantage applies to all employees on a + given salary structure (e.g. company car for executives); +- by employee tag (``hr.employee.category``): an advantage applies to + every employee tagged accordingly (e.g. meal voucher for on-site + staff); +- by department: an advantage applies to a whole department (e.g. + IT support stipend); +- by job position: an advantage applies to a specific position; +- by company, country, or any other criterion the integrator needs. + +Each criterion is implemented by its own small bridge module that +overrides the hook and provides the relevant trigger. diff --git a/payroll_contract_advantages_base/readme/USAGE.md b/payroll_contract_advantages_base/readme/USAGE.md new file mode 100644 index 000000000..7224b2b91 --- /dev/null +++ b/payroll_contract_advantages_base/readme/USAGE.md @@ -0,0 +1,58 @@ +This module is a technical base; install a bridge module (e.g. +``payroll_contract_advantages_structure``) to enable the +synchronization with a real criterion. + +For module developers, a bridge module needs to: + +1. attach the templates to its criterion (typically a Many2many); +2. override ``_get_applicable_advantage_templates`` on ``hr.contract`` + to return the templates resolved from that criterion; +3. call ``_sync_advantages_from_templates()`` from the trigger that + makes sense for the criterion (onchange, button, write override). + +Example: bridge module ``payroll_contract_advantages_structure`` +(criterion = salary structure): + +```python +from odoo import api, fields, models + + +class HrContractAdvantageTemplate(models.Model): + _inherit = "hr.contract.advantage.template" + + structure_ids = fields.Many2many( + comodel_name="hr.payroll.structure", + relation="hr_contract_advantage_template_structure_rel", + column1="template_id", + column2="structure_id", + string="Salary Structures", + ) + + +class HrPayrollStructure(models.Model): + _inherit = "hr.payroll.structure" + + advantage_template_ids = fields.Many2many( + comodel_name="hr.contract.advantage.template", + relation="hr_contract_advantage_template_structure_rel", + column1="structure_id", + column2="template_id", + string="Advantage Templates", + ) + + +class HrContract(models.Model): + _inherit = "hr.contract" + + def _get_applicable_advantage_templates(self): + return self.struct_id.advantage_template_ids + + @api.onchange("struct_id") + def _onchange_struct_id_sync_advantages(self): + for contract in self: + contract._sync_advantages_from_templates() +``` + +The same pattern applies to any other criterion (employee tags, +department, job position...): the bridge module declares the link, +overrides the hook and picks the right trigger. diff --git a/payroll_contract_advantages_base/static/description/index.html b/payroll_contract_advantages_base/static/description/index.html new file mode 100644 index 000000000..783944582 --- /dev/null +++ b/payroll_contract_advantages_base/static/description/index.html @@ -0,0 +1,502 @@ + + + + + +Payroll Contract Advantages Base + + + +
+

Payroll Contract Advantages Base

+ + +

Beta License: LGPL-3 OCA/payroll Translate me on Weblate Try me on Runboat

+

Provides an abstract sync mechanism that creates a +hr.contract.advantage from each applicable +hr.contract.advantage.template on a contract. The mechanism is +additive only: existing advantages on the contract are never removed nor +overwritten.

+

This base module does not select any template by itself. It exposes a +hook to be plugged by bridge modules, which can attach templates to the +criterion that fits their needs, for example:

+
    +
  • by salary structure: an advantage applies to all employees on a given +salary structure (e.g. company car for executives);
  • +
  • by employee tag (hr.employee.category): an advantage applies to +every employee tagged accordingly (e.g. meal voucher for on-site +staff);
  • +
  • by department: an advantage applies to a whole department (e.g. IT +support stipend);
  • +
  • by job position: an advantage applies to a specific position;
  • +
  • by company, country, or any other criterion the integrator needs.
  • +
+

Each criterion is implemented by its own small bridge module that +overrides the hook and provides the relevant trigger.

+

Table of contents

+ +
+

Usage

+

This module is a technical base; install a bridge module (e.g. +payroll_contract_advantages_structure) to enable the synchronization +with a real criterion.

+

For module developers, a bridge module needs to:

+
    +
  1. attach the templates to its criterion (typically a Many2many);
  2. +
  3. override _get_applicable_advantage_templates on hr.contract +to return the templates resolved from that criterion;
  4. +
  5. call _sync_advantages_from_templates() from the trigger that +makes sense for the criterion (onchange, button, write override).
  6. +
+

Example: bridge module payroll_contract_advantages_structure +(criterion = salary structure):

+
+from odoo import api, fields, models
+
+
+class HrContractAdvantageTemplate(models.Model):
+    _inherit = "hr.contract.advantage.template"
+
+    structure_ids = fields.Many2many(
+        comodel_name="hr.payroll.structure",
+        relation="hr_contract_advantage_template_structure_rel",
+        column1="template_id",
+        column2="structure_id",
+        string="Salary Structures",
+    )
+
+
+class HrPayrollStructure(models.Model):
+    _inherit = "hr.payroll.structure"
+
+    advantage_template_ids = fields.Many2many(
+        comodel_name="hr.contract.advantage.template",
+        relation="hr_contract_advantage_template_structure_rel",
+        column1="structure_id",
+        column2="template_id",
+        string="Advantage Templates",
+    )
+
+
+class HrContract(models.Model):
+    _inherit = "hr.contract"
+
+    def _get_applicable_advantage_templates(self):
+        return self.struct_id.advantage_template_ids
+
+    @api.onchange("struct_id")
+    def _onchange_struct_id_sync_advantages(self):
+        for contract in self:
+            contract._sync_advantages_from_templates()
+
+

The same pattern applies to any other criterion (employee tags, +department, job position…): the bridge module declares the link, +overrides the hook and picks the right trigger.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • INVITU
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/payroll project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/payroll_contract_advantages_structure/README.rst b/payroll_contract_advantages_structure/README.rst new file mode 100644 index 000000000..2d550e0dc --- /dev/null +++ b/payroll_contract_advantages_structure/README.rst @@ -0,0 +1,97 @@ +=============================================== +Payroll Contract Advantages by Salary Structure +=============================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:4e2708b2de5111c54bcb121100caac1601de32c223a26a0ed4ef73180af13832 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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 + :target: https://github.com/OCA/payroll/tree/18.0/payroll_contract_advantages_structure + :alt: OCA/payroll +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/payroll-18-0/payroll-18-0-payroll_contract_advantages_structure + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/payroll&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Attach advantage templates to salary structures and auto-create the +matching ``hr.contract.advantage`` lines on the contract when the +structure is selected. Additive only: existing advantages are kept. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +1. Go to *Payroll → Configuration → Advantage Templates* and define the + advantage templates you need (name, code, computation mode, quantity + mode...). +2. On each template, set the *Salary Structures* field to the structures + it applies to. Alternatively, on the *Salary Structure* form, the + *Advantage Templates* tab lists the templates attached to that + structure. + +Usage +===== + +- Selecting a salary structure on a contract auto-creates the advantages + corresponding to its templates that are not already present on the + contract. +- Existing advantages on the contract are never removed nor overwritten. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* INVITU + +Contributors +------------ + +- Cyril VINH-TUNG + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/payroll `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/payroll_contract_advantages_structure/__init__.py b/payroll_contract_advantages_structure/__init__.py new file mode 100644 index 000000000..af378d5cf --- /dev/null +++ b/payroll_contract_advantages_structure/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import models diff --git a/payroll_contract_advantages_structure/__manifest__.py b/payroll_contract_advantages_structure/__manifest__.py new file mode 100644 index 000000000..1b9cefd42 --- /dev/null +++ b/payroll_contract_advantages_structure/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": "Payroll Contract Advantages by Salary Structure", + "version": "18.0.1.0.0", + "category": "Payroll", + "website": "https://github.com/OCA/payroll", + "summary": "Attach advantage templates to salary structures and" + " auto-create the advantages on the contract.", + "license": "LGPL-3", + "author": "INVITU, Odoo Community Association (OCA)", + "depends": ["payroll_contract_advantages_base"], + "data": [ + "views/hr_contract_advantage_template_views.xml", + "views/hr_payroll_structure_views.xml", + ], + "demo": ["demo/payroll_contract_advantages_structure_demo.xml"], + "installable": True, +} diff --git a/payroll_contract_advantages_structure/demo/payroll_contract_advantages_structure_demo.xml b/payroll_contract_advantages_structure/demo/payroll_contract_advantages_structure_demo.xml new file mode 100644 index 000000000..99b4ac8fc --- /dev/null +++ b/payroll_contract_advantages_structure/demo/payroll_contract_advantages_structure_demo.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/payroll_contract_advantages_structure/models/__init__.py b/payroll_contract_advantages_structure/models/__init__.py new file mode 100644 index 000000000..a4477323e --- /dev/null +++ b/payroll_contract_advantages_structure/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import hr_contract_advantage_template +from . import hr_payroll_structure +from . import hr_contract diff --git a/payroll_contract_advantages_structure/models/hr_contract.py b/payroll_contract_advantages_structure/models/hr_contract.py new file mode 100644 index 000000000..c7c47c9a4 --- /dev/null +++ b/payroll_contract_advantages_structure/models/hr_contract.py @@ -0,0 +1,18 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import api, models + + +class HrContract(models.Model): + _inherit = "hr.contract" + + def _get_applicable_advantage_templates(self): + """Templates rattached to the contract's salary structure.""" + self.ensure_one() + return self.struct_id.advantage_template_ids + + @api.onchange("struct_id") + def _onchange_struct_id_sync_advantages(self): + for contract in self: + contract._sync_advantages_from_templates() diff --git a/payroll_contract_advantages_structure/models/hr_contract_advantage_template.py b/payroll_contract_advantages_structure/models/hr_contract_advantage_template.py new file mode 100644 index 000000000..d404dec14 --- /dev/null +++ b/payroll_contract_advantages_structure/models/hr_contract_advantage_template.py @@ -0,0 +1,18 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import fields, models + + +class HrContractAdvantageTemplate(models.Model): + _inherit = "hr.contract.advantage.template" + + structure_ids = fields.Many2many( + comodel_name="hr.payroll.structure", + relation="hr_contract_advantage_template_structure_rel", + column1="template_id", + column2="structure_id", + string="Salary Structures", + help="Salary structures that auto-create this advantage on the" + " contract when selected.", + ) diff --git a/payroll_contract_advantages_structure/models/hr_payroll_structure.py b/payroll_contract_advantages_structure/models/hr_payroll_structure.py new file mode 100644 index 000000000..f4dc6c4b3 --- /dev/null +++ b/payroll_contract_advantages_structure/models/hr_payroll_structure.py @@ -0,0 +1,17 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import fields, models + + +class HrPayrollStructure(models.Model): + _inherit = "hr.payroll.structure" + + advantage_template_ids = fields.Many2many( + comodel_name="hr.contract.advantage.template", + relation="hr_contract_advantage_template_structure_rel", + column1="structure_id", + column2="template_id", + string="Advantage Templates", + help="Templates auto-created on a contract when this structure" " is selected.", + ) diff --git a/payroll_contract_advantages_structure/pyproject.toml b/payroll_contract_advantages_structure/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/payroll_contract_advantages_structure/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/payroll_contract_advantages_structure/readme/CONFIGURE.md b/payroll_contract_advantages_structure/readme/CONFIGURE.md new file mode 100644 index 000000000..24e00b55b --- /dev/null +++ b/payroll_contract_advantages_structure/readme/CONFIGURE.md @@ -0,0 +1,7 @@ +1. Go to *Payroll → Configuration → Advantage Templates* and define + the advantage templates you need (name, code, computation mode, + quantity mode...). +2. On each template, set the *Salary Structures* field to the + structures it applies to. Alternatively, on the *Salary Structure* + form, the *Advantage Templates* tab lists the templates attached + to that structure. diff --git a/payroll_contract_advantages_structure/readme/CONTRIBUTORS.md b/payroll_contract_advantages_structure/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..e552444b7 --- /dev/null +++ b/payroll_contract_advantages_structure/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Cyril VINH-TUNG \<\> diff --git a/payroll_contract_advantages_structure/readme/DESCRIPTION.md b/payroll_contract_advantages_structure/readme/DESCRIPTION.md new file mode 100644 index 000000000..bb6662c00 --- /dev/null +++ b/payroll_contract_advantages_structure/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +Attach advantage templates to salary structures and auto-create the +matching ``hr.contract.advantage`` lines on the contract when the +structure is selected. Additive only: existing advantages are kept. diff --git a/payroll_contract_advantages_structure/readme/USAGE.md b/payroll_contract_advantages_structure/readme/USAGE.md new file mode 100644 index 000000000..d83d1053a --- /dev/null +++ b/payroll_contract_advantages_structure/readme/USAGE.md @@ -0,0 +1,5 @@ +- Selecting a salary structure on a contract auto-creates the + advantages corresponding to its templates that are not already + present on the contract. +- Existing advantages on the contract are never removed nor + overwritten. diff --git a/payroll_contract_advantages_structure/static/description/index.html b/payroll_contract_advantages_structure/static/description/index.html new file mode 100644 index 000000000..7179b3bff --- /dev/null +++ b/payroll_contract_advantages_structure/static/description/index.html @@ -0,0 +1,448 @@ + + + + + +Payroll Contract Advantages by Salary Structure + + + +
+

Payroll Contract Advantages by Salary Structure

+ + +

Beta License: LGPL-3 OCA/payroll Translate me on Weblate Try me on Runboat

+

Attach advantage templates to salary structures and auto-create the +matching hr.contract.advantage lines on the contract when the +structure is selected. Additive only: existing advantages are kept.

+

Table of contents

+ +
+

Configuration

+
    +
  1. Go to Payroll → Configuration → Advantage Templates and define the +advantage templates you need (name, code, computation mode, quantity +mode…).
  2. +
  3. On each template, set the Salary Structures field to the structures +it applies to. Alternatively, on the Salary Structure form, the +Advantage Templates tab lists the templates attached to that +structure.
  4. +
+
+
+

Usage

+
    +
  • Selecting a salary structure on a contract auto-creates the advantages +corresponding to its templates that are not already present on the +contract.
  • +
  • Existing advantages on the contract are never removed nor overwritten.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • INVITU
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/payroll project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/payroll_contract_advantages_structure/tests/__init__.py b/payroll_contract_advantages_structure/tests/__init__.py new file mode 100644 index 000000000..2a7de1b47 --- /dev/null +++ b/payroll_contract_advantages_structure/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import test_payroll_contract_advantages_structure diff --git a/payroll_contract_advantages_structure/tests/test_payroll_contract_advantages_structure.py b/payroll_contract_advantages_structure/tests/test_payroll_contract_advantages_structure.py new file mode 100644 index 000000000..9b5989d4a --- /dev/null +++ b/payroll_contract_advantages_structure/tests/test_payroll_contract_advantages_structure.py @@ -0,0 +1,58 @@ +# Copyright 2026 INVITU () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.addons.payroll.tests.common import TestPayslipBase + + +class TestPayrollContractAdvantagesStructure(TestPayslipBase): + def setUp(self): + super().setUp() + self.Template = self.env["hr.contract.advantage.template"] + self.Advantage = self.env["hr.contract.advantage"] + self.structure = self.developer_pay_structure + self.template = self.Template.create( + { + "name": "Meal", + "code": "MEAL", + "default_value": 10.0, + "upper_bound": 1000.0, + "structure_ids": [(6, 0, [self.structure.id])], + } + ) + + def test_hook_returns_templates_of_structure(self): + self.richard_contract.struct_id = self.structure + templates = self.richard_contract._get_applicable_advantage_templates() + self.assertIn(self.template, templates) + + def test_sync_creates_missing_advantage(self): + self.richard_contract.struct_id = self.structure + self.richard_contract._sync_advantages_from_templates() + advantages = self.richard_contract.advantages_ids + self.assertEqual(len(advantages), 1) + self.assertEqual(advantages.advantage_template_id, self.template) + self.assertEqual(advantages.amount, 10.0) + + def test_sync_is_idempotent(self): + self.richard_contract.struct_id = self.structure + self.richard_contract._sync_advantages_from_templates() + self.richard_contract._sync_advantages_from_templates() + self.assertEqual(len(self.richard_contract.advantages_ids), 1) + + def test_sync_is_additive_only(self): + """Pre-existing advantages are kept untouched.""" + other = self.Template.create( + {"name": "Phone", "code": "PHONE", "default_value": 5.0} + ) + self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": other.id, + "amount": 5.0, + } + ) + self.richard_contract.struct_id = self.structure + self.richard_contract._sync_advantages_from_templates() + templates = self.richard_contract.advantages_ids.advantage_template_id + self.assertIn(self.template, templates) + self.assertIn(other, templates) diff --git a/payroll_contract_advantages_structure/views/hr_contract_advantage_template_views.xml b/payroll_contract_advantages_structure/views/hr_contract_advantage_template_views.xml new file mode 100644 index 000000000..460bed36a --- /dev/null +++ b/payroll_contract_advantages_structure/views/hr_contract_advantage_template_views.xml @@ -0,0 +1,37 @@ + + + + + + hr.contract.advantage.template.form.inherit.structure + + hr.contract.advantage.template + + + + + + + + + + + + hr.contract.advantage.template.tree.inherit.structure + + hr.contract.advantage.template + + + + + + + + diff --git a/payroll_contract_advantages_structure/views/hr_payroll_structure_views.xml b/payroll_contract_advantages_structure/views/hr_payroll_structure_views.xml new file mode 100644 index 000000000..e2587316f --- /dev/null +++ b/payroll_contract_advantages_structure/views/hr_payroll_structure_views.xml @@ -0,0 +1,26 @@ + + + + + + hr.payroll.structure.form.inherit.advantage_templates + + hr.payroll.structure + + + + + + + + + + + + + + + + + diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..5b79dd104 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-payroll_contract_advantages @ git+https://github.com/OCA/payroll.git@refs/pull/267/head#subdirectory=payroll_contract_advantages