diff --git a/payroll_contract_advantages/README.rst b/payroll_contract_advantages/README.rst index 29bf2e97c..adfdb7e7e 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 @@ -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** @@ -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 =========== diff --git a/payroll_contract_advantages/__manifest__.py b/payroll_contract_advantages/__manifest__.py index f8351fdbb..bb82feaf0 100644 --- a/payroll_contract_advantages/__manifest__.py +++ b/payroll_contract_advantages/__manifest__.py @@ -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.", diff --git a/payroll_contract_advantages/migrations/18.0.2.0.0/pre-migration.py b/payroll_contract_advantages/migrations/18.0.2.0.0/pre-migration.py new file mode 100644 index 000000000..04dbdf134 --- /dev/null +++ b/payroll_contract_advantages/migrations/18.0.2.0.0/pre-migration.py @@ -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 + """ + ) diff --git a/payroll_contract_advantages/models/hr_contract_advantage.py b/payroll_contract_advantages/models/hr_contract_advantage.py index ea3cd0b94..636268e66 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,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) diff --git a/payroll_contract_advantages/models/hr_contract_advantage_template.py b/payroll_contract_advantages/models/hr_contract_advantage_template.py index d3db791b0..6d2d55ce6 100644 --- a/payroll_contract_advantages/models/hr_contract_advantage_template.py +++ b/payroll_contract_advantages/models/hr_contract_advantage_template.py @@ -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() diff --git a/payroll_contract_advantages/models/hr_payslip.py b/payroll_contract_advantages/models/hr_payslip.py index 9d4170cbb..0d31dd9e3 100644 --- a/payroll_contract_advantages/models/hr_payslip.py +++ b/payroll_contract_advantages/models/hr_payslip.py @@ -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)} ) diff --git a/payroll_contract_advantages/readme/DESCRIPTION.md b/payroll_contract_advantages/readme/DESCRIPTION.md index 21305a80b..57bc623d3 100644 --- a/payroll_contract_advantages/readme/DESCRIPTION.md +++ b/payroll_contract_advantages/readme/DESCRIPTION.md @@ -1,4 +1,8 @@ -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. diff --git a/payroll_contract_advantages/readme/USAGE.md b/payroll_contract_advantages/readme/USAGE.md index 0dc71a47c..8df6f28d2 100644 --- a/payroll_contract_advantages/readme/USAGE.md +++ b/payroll_contract_advantages/readme/USAGE.md @@ -1,6 +1,14 @@ -- 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]``. diff --git a/payroll_contract_advantages/static/description/index.html b/payroll_contract_advantages/static/description/index.html index c0e8e1bf6..1c45ac770 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

-

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.

+

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

+

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

    @@ -393,18 +391,26 @@

    Payroll Contract Advantages

-

Usage

+

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

+

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 +418,22 @@

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 +448,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..d83329bd7 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 @@ -114,3 +114,259 @@ def test_get_current_contract_dict_contains_advantages(self): self.assertIsNotNone(advantages) self.assertEqual(advantages.FUEL, 30.0) + + # ------------------------------------------------------------------ + # Computation modes (enhancement 18.0.2.0.0) + # ------------------------------------------------------------------ + + 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") + self.assertEqual(advantage.fixed_value, 99.0) + # Historical 'amount' still populated from default value. + self.assertEqual(advantage.amount, 99.0) + + def test_fixed_mode_amount_equals_fixed_value(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", + "fixed_value": 150.0, + "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 (enhancement: 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", + "fixed_value": 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_advantage_param_accessible_in_formula(self): + """The quantity formula can read advantage.quantity_fixed_value + as a free parameter (pattern used by PF meal templates: + meals_per_day stored there).""" + template = self._create_template( + lower=0.0, upper=1000000.0, code="QPARAM", name="QtyParam" + ) + adv = self.Advantage.create( + { + "contract_id": self.richard_contract.id, + "advantage_template_id": template.id, + "computation_mode": "fixed", + "fixed_value": 410.0, + "quantity_mode": "python", + "quantity_fixed_value": 2.0, # e.g. meals per day + "quantity_python_code": "result = 10 * advantage.quantity_fixed_value", + } + ) + # 10 days x 2 meals/day x 410 unit value + self.assertAlmostEqual(adv._compute_advantage_amount(), 10 * 2.0 * 410.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..e2874f4bf 100644 --- a/payroll_contract_advantages/views/hr_contract_advantage_views.xml +++ b/payroll_contract_advantages/views/hr_contract_advantage_views.xml @@ -17,11 +17,48 @@ - - + + + + + + + + + + + + + + @@ -34,6 +71,7 @@ + diff --git a/payroll_contract_advantages/views/hr_contract_views.xml b/payroll_contract_advantages/views/hr_contract_views.xml index dfe5643b4..fa63e5806 100644 --- a/payroll_contract_advantages/views/hr_contract_views.xml +++ b/payroll_contract_advantages/views/hr_contract_views.xml @@ -16,6 +16,14 @@ + + + + + + + +