Skip to content

Commit 01040b1

Browse files
committed
[IMP] payroll_contract_advantages: add complex mechanism for advantage calculations - helped by Claude Opus-4.7
1 parent 59ae313 commit 01040b1

12 files changed

Lines changed: 650 additions & 73 deletions

File tree

payroll_contract_advantages/README.rst

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
.. image:: https://odoo-community.org/readme-banner-image
2-
:target: https://odoo-community.org/get-involved?utm_source=readme
3-
:alt: Odoo Community Association
4-
51
===========================
62
Payroll Contract Advantages
73
===========================
@@ -17,7 +13,7 @@ Payroll Contract Advantages
1713
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
1814
:target: https://odoo-community.org/page/development-status
1915
:alt: Beta
20-
.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png
16+
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
2117
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
2218
:alt: License: LGPL-3
2319
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpayroll-lightgray.png?logo=github
@@ -32,10 +28,14 @@ Payroll Contract Advantages
3228

3329
|badge1| |badge2| |badge3| |badge4| |badge5|
3430

35-
This module adds support for advantages templates and advantages to be
36-
set in contract form. The advantages can be set in the contract form as
37-
a list of advantages templates. Then it can be used in the calculation
38-
of the salary rules.
31+
This module lets you define advantage templates and set advantages on
32+
the contract form, for use in salary rule computation.
33+
34+
Each advantage has a computation mode (fixed value, percentage of a
35+
contract field, or Python expression) and a quantity mode (fixed or
36+
Python); the amount is quantity x unit value, re-evaluated per payslip.
37+
The default fixed mode reproduces the historical behaviour. Template
38+
bounds are enforced on the final amount.
3939

4040
**Table of contents**
4141

@@ -45,12 +45,20 @@ of the salary rules.
4545
Usage
4646
=====
4747

48-
- Set the advantages templates in the payroll module with lower and
49-
upper bounds and default value.
50-
- Go to the employee contract and add the advantages that you want for
51-
this contract, default value will be populated but you can change it.
52-
- Then in the salary rules, access this value using
53-
current_contract.advantages.[ADVANTAGE_CODE] (without brackets)
48+
- Create advantage templates with lower/upper bounds, a computation mode
49+
(fixed value, percentage of a contract field, or Python code) and a
50+
quantity mode (fixed or Python code).
51+
- Add advantages on the employee contract. The definition is copied from
52+
the template and can be tuned per contract.
53+
- The amount is **quantity x unit value**, re-evaluated for each
54+
payslip. Python formulas expose ``advantage``, ``contract``,
55+
``employee``, ``payslip`` and must set ``result``. The ``Quantity``
56+
field is also a free parameter readable via
57+
``advantage.quantity_fixed_value``.
58+
- Bounds are enforced on the final amount; a non-numeric formula result
59+
raises an error.
60+
- In salary rules, read the value with
61+
``current_contract.advantages.[ADVANTAGE_CODE]``.
5462

5563
Bug Tracker
5664
===========

payroll_contract_advantages/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
{
44
"name": "Payroll Contract Advantages",
5-
"version": "18.0.1.0.0",
5+
"version": "18.0.2.0.0",
66
"category": "Payroll",
77
"website": "https://github.com/OCA/payroll",
88
"summary": "Allow to define contract advantages for employees.",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
"""Init new fields so existing records keep the historical behaviour."""
3+
4+
5+
def migrate(cr, version):
6+
if not version:
7+
return
8+
# Backfill fixed_value from the historical amount.
9+
cr.execute(
10+
"""
11+
UPDATE hr_contract_advantage
12+
SET fixed_value = amount
13+
WHERE fixed_value IS NULL OR fixed_value = 0.0
14+
"""
15+
)
16+
# Make computation_mode explicit (cover any leftover NULL).
17+
cr.execute(
18+
"""
19+
UPDATE hr_contract_advantage
20+
SET computation_mode = 'fixed'
21+
WHERE computation_mode IS NULL
22+
"""
23+
)
24+
cr.execute(
25+
"""
26+
UPDATE hr_contract_advantage_template
27+
SET computation_mode = 'fixed'
28+
WHERE computation_mode IS NULL
29+
"""
30+
)
31+
# Quantity model: default fixed 1.0 -> amount = unit value.
32+
cr.execute(
33+
"""
34+
UPDATE hr_contract_advantage
35+
SET quantity_mode = 'fixed'
36+
WHERE quantity_mode IS NULL
37+
"""
38+
)
39+
cr.execute(
40+
"""
41+
UPDATE hr_contract_advantage_template
42+
SET quantity_fixed_value = 1.0
43+
WHERE quantity_fixed_value IS NULL OR quantity_fixed_value = 0.0
44+
"""
45+
)
Lines changed: 167 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Part of Odoo. See LICENSE file for full copyright and licensing details.
22

33
from odoo import _, api, fields, models
4-
from odoo.exceptions import ValidationError
4+
from odoo.exceptions import UserError, ValidationError
5+
from odoo.tools.safe_eval import safe_eval
56

67

78
class HrContractAdvantage(models.Model):
@@ -21,22 +22,176 @@ class HrContractAdvantage(models.Model):
2122
advantage_upper_bound = fields.Float(
2223
string="Upper Bound", related="advantage_template_id.upper_bound", readonly=True
2324
)
24-
amount = fields.Float()
25+
26+
# Definition copied from the template on selection, then editable
27+
# per contract. Default "fixed" + fixed_value reproduces the
28+
# historical amount behaviour (see migration).
29+
computation_mode = fields.Selection(
30+
selection=[
31+
("fixed", "Fixed value"),
32+
("percentage", "Percentage of a contract field"),
33+
("python", "Python code"),
34+
],
35+
default="fixed",
36+
required=True,
37+
)
38+
fixed_value = fields.Float(help="Value used in 'Fixed value' mode.")
39+
percentage = fields.Float(help="Percentage applied to the base field.")
40+
percentage_base = fields.Char(
41+
string="Percentage Base Field",
42+
help="Numeric hr.contract field used as base (e.g. 'wage').",
43+
)
44+
python_code = fields.Text(
45+
help="Expression with: advantage, contract, employee, payslip. "
46+
"Set 'result'.",
47+
)
48+
quantity_mode = fields.Selection(
49+
selection=[
50+
("fixed", "Fixed quantity"),
51+
("python", "Python code"),
52+
],
53+
default="fixed",
54+
required=True,
55+
)
56+
quantity_fixed_value = fields.Float(
57+
string="Quantity",
58+
default=1.0,
59+
help="Quantity used in 'Fixed quantity' mode.",
60+
)
61+
quantity_python_code = fields.Text(
62+
help="Expression with: advantage, contract, employee, payslip. "
63+
"Set 'result'.",
64+
)
65+
amount = fields.Float(
66+
help="Latest evaluated amount (quantity x unit value), "
67+
"recomputed per payslip for non-fixed modes."
68+
)
2569

2670
@api.onchange("advantage_template_id")
2771
def _onchange_advantage_template_id(self):
72+
"""Copy the template definition onto the advantage.
73+
74+
``amount`` is still populated from the template default value so
75+
existing flows and the 'fixed' mode behave as before.
76+
"""
2877
for record in self:
29-
record.amount = record.advantage_template_id.default_value
78+
template = record.advantage_template_id
79+
if not template:
80+
continue
81+
record.computation_mode = template.computation_mode
82+
record.fixed_value = template.default_value
83+
record.percentage = template.percentage
84+
record.percentage_base = template.percentage_base
85+
record.python_code = template.python_code
86+
record.quantity_mode = template.quantity_mode
87+
record.quantity_fixed_value = template.quantity_fixed_value
88+
record.quantity_python_code = template.quantity_python_code
89+
record.amount = template.default_value
90+
91+
def _compute_advantage_amount(self, payslip=None):
92+
"""Return quantity x unit value, bounded. Evaluated per payslip.
93+
94+
:param payslip: optional hr.payslip, exposed to python formulas.
95+
"""
96+
self.ensure_one()
97+
unit_value = self._compute_unit_value(payslip=payslip)
98+
quantity = self._compute_quantity(payslip=payslip)
99+
amount = quantity * unit_value
100+
self._check_bounds(amount)
101+
return amount
102+
103+
def _compute_unit_value(self, payslip=None):
104+
"""Unit value per the computation mode."""
105+
self.ensure_one()
106+
contract = self.contract_id
107+
mode = self.computation_mode or "fixed"
108+
109+
if mode == "fixed":
110+
# Backward compatibility: historically the amount was typed
111+
# directly on the advantage (no fixed_value field). If
112+
# fixed_value was never set but amount was, keep using
113+
# amount so existing flows/records are unaffected.
114+
if not self.fixed_value and self.amount:
115+
value = self.amount
116+
else:
117+
value = self.fixed_value
118+
elif mode == "percentage":
119+
base_field = (self.percentage_base or "").strip()
120+
base_value = 0.0
121+
if base_field and contract:
122+
base_value = contract[base_field] if base_field in contract else 0.0
123+
value = (base_value or 0.0) * (self.percentage or 0.0) / 100.0
124+
elif mode == "python":
125+
value = self._eval_code(self.python_code, payslip=payslip)
126+
else:
127+
value = 0.0
128+
129+
return self._coerce_float(value, _("unit value"))
130+
131+
def _compute_quantity(self, payslip=None):
132+
"""Quantity per the quantity mode. Default fixed 1.0."""
133+
self.ensure_one()
134+
mode = self.quantity_mode or "fixed"
135+
if mode == "python":
136+
value = self._eval_code(self.quantity_python_code, payslip=payslip)
137+
else:
138+
# An explicit 0 quantity is valid (amount 0).
139+
value = (
140+
self.quantity_fixed_value
141+
if self.quantity_fixed_value is not False
142+
else 1.0
143+
)
144+
return self._coerce_float(value, _("quantity"))
145+
146+
def _coerce_float(self, value, label):
147+
"""Float guarantee, mirroring hr.salary.rule._compute_rule."""
148+
try:
149+
return float(value)
150+
except (TypeError, ValueError) as err:
151+
raise UserError(
152+
_(
153+
"The computed %(label)s of advantage "
154+
"'%(advantage)s' must be a float."
155+
)
156+
% {
157+
"label": label,
158+
"advantage": self.advantage_template_id.name
159+
or self.advantage_template_code
160+
or self.id,
161+
}
162+
) from err
163+
164+
def _eval_code(self, code, payslip=None):
165+
"""Safely evaluate a generic python expression."""
166+
self.ensure_one()
167+
if not code:
168+
return 0.0
169+
localdict = {
170+
"advantage": self,
171+
"contract": self.contract_id,
172+
"employee": self.contract_id.employee_id
173+
if self.contract_id
174+
else self.env["hr.employee"],
175+
"payslip": payslip,
176+
"result": 0.0,
177+
}
178+
safe_eval(code, localdict, mode="exec", nocopy=True)
179+
return localdict.get("result", 0.0) or 0.0
180+
181+
def _check_bounds(self, value):
182+
"""Enforce template lower/upper bounds on a candidate amount."""
183+
self.ensure_one()
184+
if value and value != 0.00:
185+
if self.advantage_upper_bound and value > self.advantage_upper_bound:
186+
raise ValidationError(
187+
_("Advantage amount can't be greater than upper bound limit.")
188+
)
189+
elif self.advantage_lower_bound and value < self.advantage_lower_bound:
190+
raise ValidationError(
191+
_("Advantage amount can't be less than lower bound limit.")
192+
)
30193

31194
@api.constrains("amount")
32195
def _check_bound_limits(self):
33196
for record in self:
34-
if record.amount and record.amount != 0.00:
35-
if record.amount > record.advantage_upper_bound:
36-
raise ValidationError(
37-
_("Advantage amount can't be greater than upper bound limit.")
38-
)
39-
elif record.amount < record.advantage_lower_bound:
40-
raise ValidationError(
41-
_("Advantage amount can't be less than lower bound limit.")
42-
)
197+
record._check_bounds(record.amount)

payroll_contract_advantages/models/hr_contract_advantage_template.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,49 @@ class HrContractAdvandageTemplate(models.Model):
99

1010
name = fields.Char(required=True)
1111
code = fields.Char(required=True)
12-
lower_bound = fields.Float(
13-
help="Lower bound authorized by the employer for this advantage"
12+
lower_bound = fields.Float(help="Lower bound authorized for this advantage")
13+
upper_bound = fields.Float(help="Upper bound authorized for this advantage")
14+
default_value = fields.Float()
15+
16+
# Default "fixed" keeps the historical behaviour (amount =
17+
# default_value), so existing databases are unaffected.
18+
computation_mode = fields.Selection(
19+
selection=[
20+
("fixed", "Fixed value"),
21+
("percentage", "Percentage of a contract field"),
22+
("python", "Python code"),
23+
],
24+
default="fixed",
25+
required=True,
26+
help="How the unit value is computed.",
1427
)
15-
upper_bound = fields.Float(
16-
help="Upper bound authorized by the employer for this advantage"
28+
percentage = fields.Float(help="Percentage applied to the base field.")
29+
percentage_base = fields.Char(
30+
string="Percentage Base Field",
31+
help="Numeric hr.contract field used as base (e.g. 'wage').",
32+
)
33+
python_code = fields.Text(
34+
help="Expression with: advantage, contract, employee, payslip. "
35+
"Set 'result'. E.g. result = contract.wage * 0.05",
36+
)
37+
38+
# Final amount = quantity * unit value. Default fixed quantity 1.0
39+
# keeps the historical behaviour (amount = unit value).
40+
quantity_mode = fields.Selection(
41+
selection=[
42+
("fixed", "Fixed quantity"),
43+
("python", "Python code"),
44+
],
45+
default="fixed",
46+
required=True,
47+
help="How the quantity is computed.",
48+
)
49+
quantity_fixed_value = fields.Float(
50+
string="Quantity",
51+
default=1.0,
52+
help="Quantity used in 'Fixed quantity' mode.",
53+
)
54+
quantity_python_code = fields.Text(
55+
help="Expression with: advantage, contract, employee, payslip. "
56+
"Set 'result'.",
1757
)
18-
default_value = fields.Float()

payroll_contract_advantages/models/hr_payslip.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,22 @@ class HrPayslip(models.Model):
99
_inherit = "hr.payslip"
1010

1111
def get_current_contract_dict(self, contract, contracts):
12+
"""Expose advantages by code in the salary rules localdict.
13+
14+
Amounts are (re)evaluated per payslip from each advantage's
15+
formula, so period-sensitive values stay correct. In 'fixed'
16+
mode the value equals fixed_value, unchanged for existing
17+
installations.
18+
"""
1219
self.ensure_one()
1320
res = super().get_current_contract_dict(contract, contracts)
1421
advantages_dict = {}
1522
for advantage in contract.advantages_ids:
16-
advantages_dict[advantage.advantage_template_code] = advantage.amount
23+
amount = advantage._compute_advantage_amount(payslip=self)
24+
# Keep the stored amount in sync for reporting / auditing.
25+
if advantage.amount != amount:
26+
advantage.amount = amount
27+
advantages_dict[advantage.advantage_template_code] = amount
1728
res.update(
1829
{"advantages": BrowsableObject(self.employee_id, advantages_dict, self.env)}
1930
)

0 commit comments

Comments
 (0)