Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions sale_loyalty_margin_computation/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
===============================
Sale Loyalty Margin Computation
===============================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:9130c282b0515dc9f3fe5dfb48a3eb3f560b930e1067711ba89332ad2cc697c9
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |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-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmargin--analysis-lightgray.png?logo=github
:target: https://github.com/OCA/margin-analysis/tree/18.0/sale_loyalty_margin_computation
:alt: OCA/margin-analysis
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/margin-analysis-18-0/margin-analysis-18-0-sale_loyalty_margin_computation
: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/margin-analysis&target_branch=18.0
:alt: Try me on Runboat

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

This module allows configuring a formula to calculate the margin of a
sale order line with loyalty rewards

**Table of contents**

.. contents::
:local:

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/margin-analysis/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 <https://github.com/OCA/margin-analysis/issues/new?body=module:%20sale_loyalty_margin_computation%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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

Credits
=======

Authors
-------

* Tecnativa

Contributors
------------

- `Tecnativa <https://www.tecnativa.com>`__:

- Pedro M. Baeza
- Andrii Kompaniiets

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/margin-analysis <https://github.com/OCA/margin-analysis/tree/18.0/sale_loyalty_margin_computation>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions sale_loyalty_margin_computation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
20 changes: 20 additions & 0 deletions sale_loyalty_margin_computation/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright 2026 Tecnativa - Andrii Kompaniiets
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Sale Loyalty Margin Computation",
"summary": "Allows to use a formula from reward for calculate sale margin",
"version": "18.0.1.0.0",
"category": "Sale",
"website": "https://github.com/OCA/margin-analysis",
"author": "Tecnativa, Odoo Community Association (OCA)",
"license": "AGPL-3",
"depends": [
"loyalty_margin_computation",
"sale_loyalty_order_line_link",
"sale_loyalty_multi_gift",
"sale_margin_pricelist_computation",
],
"data": [
"views/loyalty_reward_views.xml",
],
}
80 changes: 80 additions & 0 deletions sale_loyalty_margin_computation/i18n/es.po
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * sale_loyalty_margin_computation
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-04 06:49+0000\n"
"PO-Revision-Date: 2026-06-04 08:51+0200\n"
"Last-Translator: Andrii Kompaniiets "
"<andrii.kompaniiets@tecnativa.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 3.9\n"

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
#, fuzzy
#| msgid "<code>origin_line</code>: The origin sale order line"
msgid "<code>origin_line.product_id</code>: The Origin line product"
msgstr "<code>origin_line</code>: La línea del pedido de venta de origen"

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid "<code>origin_line</code>: The origin sale order line"
msgstr "<code>origin_line</code>: La línea del pedido de venta de origen"

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid "<code>reward_line.product_id</code>: The Reward line product"
msgstr ""
"<code>reward_line.product_id</code>:El producto de la línea de "
"recompensa"

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid "<code>reward_line</code>: The first Reward line"
msgstr "<code>reward_line</code>: La primera línea de recompensa"

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid "<code>reward_lines</code>: The Reward lines"
msgstr "<code>reward_lines</code>: Las líneas de recompensa"

#. module: sale_loyalty_margin_computation
#. odoo-python
#: code:addons/sale_loyalty_margin_computation/models/loyalty_reward.py:0
msgid ""
"Invalid sale margin formula:\n"
"%(error)s"
msgstr ""
"Fórmula de margen de venta no válida:\n"
"%(error)s"

#. module: sale_loyalty_margin_computation
#: model:ir.model,name:sale_loyalty_margin_computation.model_loyalty_reward
msgid "Loyalty Reward"
msgstr ""

#. module: sale_loyalty_margin_computation
#: model:ir.model,name:sale_loyalty_margin_computation.model_sale_order_line
msgid "Sales Order Line"
msgstr "Linea de pedido de venta"

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid ""
"product = origin_line.product_id\n"
"standard_price = product.standard_price\n"
"result = origin_line.price_unit * 1.2 * origin_line.product_uom_qty - "
"standard_price"
msgstr ""

#~ msgid "<code>env</code>: The environment"
#~ msgstr "<code>env</code>: Entorno de la línea de pedido de venta"
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * sale_loyalty_margin_computation
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-04 06:49+0000\n"
"PO-Revision-Date: 2026-06-04 06:49+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid "<code>origin_line.product_id</code>: The Origin line product"
msgstr ""

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid "<code>origin_line</code>: The origin sale order line"
msgstr ""

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid "<code>reward_line.product_id</code>: The Reward line product"
msgstr ""

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid "<code>reward_line</code>: The first Reward line"
msgstr ""

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid "<code>reward_lines</code>: The Reward lines"
msgstr ""

#. module: sale_loyalty_margin_computation
#. odoo-python
#: code:addons/sale_loyalty_margin_computation/models/loyalty_reward.py:0
msgid ""
"Invalid sale margin formula:\n"
"%(error)s"
msgstr ""

#. module: sale_loyalty_margin_computation
#: model:ir.model,name:sale_loyalty_margin_computation.model_loyalty_reward
msgid "Loyalty Reward"
msgstr ""

#. module: sale_loyalty_margin_computation
#: model:ir.model,name:sale_loyalty_margin_computation.model_sale_order_line
msgid "Sales Order Line"
msgstr ""

#. module: sale_loyalty_margin_computation
#: model_terms:ir.ui.view,arch_db:sale_loyalty_margin_computation.sale_margin_product_loyalty_reward_form_view
msgid ""
"product = origin_line.product_id\n"
"standard_price = product.standard_price\n"
"result = origin_line.price_unit * 1.2 * origin_line.product_uom_qty - standard_price"
msgstr ""
2 changes: 2 additions & 0 deletions sale_loyalty_margin_computation/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import loyalty_reward
from . import sale_order_line
59 changes: 59 additions & 0 deletions sale_loyalty_margin_computation/models/loyalty_reward.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Copyright 2026 Tecnativa - Andrii Kompaniiets
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import Command, api, models
from odoo.exceptions import ValidationError
from odoo.tools.float_utils import float_compare
from odoo.tools.safe_eval import safe_eval


class LoyaltyReward(models.Model):
_inherit = "loyalty.reward"

@api.constrains("sale_margin_formula")
def _check_sale_margin_formula(self):
res = super()._check_sale_margin_formula()
main_product = self.env["product.product"].new(
{
"name": "Main Product",
"list_price": 35.0,
"standard_price": 11.52,
}
)
order = self.env["sale.order"].new(
{
"partner_id": self.env.ref("base.partner_admin").id,
"order_line": [
Command.create(
{
"product_id": main_product.id,
"product_uom_qty": 2.0,
"price_unit": 35.0,
}
)
],
}
)
fake_eval_context = {
"env": self.env,
"user": self.env.user,
"origin_line": order.order_line[0],
"reward_line": order.order_line[0],
"reward_lines": order.order_line,
"float_compare": float_compare,
}
for reward in self:
if not reward.sale_margin_formula:
continue
fake_eval_context.update({"reward": reward})
try:
safe_eval(
str(reward.sale_margin_formula).strip(),
fake_eval_context,
mode="exec",
nocopy=True,
)
except Exception as e:
raise ValidationError(
self.env._("Invalid sale margin formula:\n%(error)s", error=e)
) from e
return res
76 changes: 76 additions & 0 deletions sale_loyalty_margin_computation/models/sale_order_line.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from odoo import api, models
from odoo.tools.float_utils import float_compare, float_round

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from odoo.tools.float_utils import float_compare, float_round
from odoo.tools.float_utils import float_compare

float_round is no longer used once the proportional distribution is gone.

from odoo.tools.safe_eval import safe_eval


class SaleOrderLine(models.Model):
_inherit = "sale.order.line"

@api.depends(
"reward_generated_line_ids",
"reward_origin_generated_line_ids",
"reward_generated_line_ids.purchase_price",
)
Comment on lines +9 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@api.depends(
"reward_generated_line_ids",
"reward_origin_generated_line_ids",
"reward_generated_line_ids.purchase_price",
)
@api.depends(
"reward_generated_line_ids",
"reward_origin_generated_line_ids",
"reward_generated_line_ids.purchase_price",
"reward_generated_line_ids.price_subtotal",
)

The transfer of the margin of rewards without a formula reads price_subtotal from the reward lines, so without this dependency the origin line would not be recomputed if that subtotal changes.

def _compute_margin(self):
res = super()._compute_margin()
for origin_line in self.filtered(lambda x: x.reward_generated_line_ids):
processed_reward = set()
calculated_margin = 0
purchase_price_total = origin_line.purchase_price
for reward_line in origin_line.reward_generated_line_ids:
purchase_price_total += reward_line.purchase_price
if (
not reward_line.reward_id.sale_margin_formula
or reward_line.reward_id.id in processed_reward
):
continue
processed_reward.add(reward_line.reward_id.id)
eval_context = origin_line._get_reward_eval_context(reward_line)
safe_eval(
str(reward_line.reward_id.sale_margin_formula).strip(),
eval_context,
mode="exec",
nocopy=True,
)
calculated_margin += eval_context.get("result", 0)
if calculated_margin:
origin_line.margin = float_round(
calculated_margin
* origin_line.purchase_price
/ purchase_price_total,
precision_digits=2,
)
margin_percent_total = calculated_margin / origin_line.price_subtotal
origin_line.margin_percent = float_round(
origin_line.purchase_price
/ purchase_price_total
* margin_percent_total,
precision_digits=2,
)
for reward_line in origin_line.reward_generated_line_ids.filtered(
lambda line: line.reward_id.sale_margin_formula
):
reward_line.margin = float_round(
calculated_margin
* reward_line.purchase_price
/ purchase_price_total,
precision_digits=2,
)
reward_line.margin_percent = float_round(
reward_line.purchase_price
/ purchase_price_total
* margin_percent_total,
precision_digits=2,
)
return res
Comment on lines +14 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _compute_margin(self):
res = super()._compute_margin()
for origin_line in self.filtered(lambda x: x.reward_generated_line_ids):
processed_reward = set()
calculated_margin = 0
purchase_price_total = origin_line.purchase_price
for reward_line in origin_line.reward_generated_line_ids:
purchase_price_total += reward_line.purchase_price
if (
not reward_line.reward_id.sale_margin_formula
or reward_line.reward_id.id in processed_reward
):
continue
processed_reward.add(reward_line.reward_id.id)
eval_context = origin_line._get_reward_eval_context(reward_line)
safe_eval(
str(reward_line.reward_id.sale_margin_formula).strip(),
eval_context,
mode="exec",
nocopy=True,
)
calculated_margin += eval_context.get("result", 0)
if calculated_margin:
origin_line.margin = float_round(
calculated_margin
* origin_line.purchase_price
/ purchase_price_total,
precision_digits=2,
)
margin_percent_total = calculated_margin / origin_line.price_subtotal
origin_line.margin_percent = float_round(
origin_line.purchase_price
/ purchase_price_total
* margin_percent_total,
precision_digits=2,
)
for reward_line in origin_line.reward_generated_line_ids.filtered(
lambda line: line.reward_id.sale_margin_formula
):
reward_line.margin = float_round(
calculated_margin
* reward_line.purchase_price
/ purchase_price_total,
precision_digits=2,
)
reward_line.margin_percent = float_round(
reward_line.purchase_price
/ purchase_price_total
* margin_percent_total,
precision_digits=2,
)
return res
def _compute_margin(self):
super()._compute_margin()
for origin_line in self.filtered("reward_generated_line_ids"):
all_reward_lines = origin_line.reward_generated_line_ids
for reward in all_reward_lines.reward_id:
reward_lines = all_reward_lines.filtered(
lambda x, reward=reward: x.reward_id == reward
)
# A reward line can be linked to several origin lines: transfer its
# margin only once, to the first of them
if origin_line != reward_lines.reward_origin_generated_line_ids[:1]:
continue
if reward.sale_margin_formula:
eval_context = origin_line._get_reward_eval_context(
reward, reward_lines
)
safe_eval(
reward.sale_margin_formula.strip(),
eval_context,
mode="exec",
nocopy=True,
)
if "result" not in eval_context:
continue
# Setting it here chains the formulas of several rewards
origin_line.margin = eval_context["result"]
else:
# The standard margin of the reward lines is recomputed, as the
# stored one is already transferred to the origin line
origin_line.margin += sum(
line.price_subtotal - line.purchase_price * line.product_uom_qty
for line in reward_lines
)
reward_lines.margin = reward_lines.margin_percent = 0
origin_line.margin_percent = (
origin_line.price_subtotal
and origin_line.margin / origin_line.price_subtotal
)

I tested this locally and found four problems in _compute_margin:

  1. Two programs add their results up. With formulas origin_line.margin + 10 and + 5 on Main × 2, the order margin is 108.91 instead of 61.96.
Image Image Image

When It should be:

Image
  1. result = 0 is ignored. if calculated_margin: treats 0 as "no formula", so the standard margin (35.96) is kept instead of 0.
Image Image

With the fix:

Image
  1. ZeroDivisionError with zero price/cost products. Free × 2 + Reward raises it in _compute_margin: neither purchase_price_total nor price_subtotal division is guarded.
Image

When you try to add the reward:

Image

With the fix:

Image
  1. A reward linked to several origin lines runs the formula once per origin line. With result = 20 on Main × 2 and Main2 × 2, the order margin is 30.23 and depends on the line order.
Image Image

Adding the fix:

Image

All four should be fixed by the suggestion above, which moves the margin of each reward to the origin line instead of distributing it among the lines.


def _get_reward_eval_context(self, reward_line):
return {
"env": self.env,
"user": self.env.user,
"origin_line": self,
"reward": reward_line.reward_id,
"reward_line": reward_line,
"reward_lines": self.reward_generated_line_ids,
"float_compare": float_compare,
}
Comment on lines +67 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _get_reward_eval_context(self, reward_line):
return {
"env": self.env,
"user": self.env.user,
"origin_line": self,
"reward": reward_line.reward_id,
"reward_line": reward_line,
"reward_lines": self.reward_generated_line_ids,
"float_compare": float_compare,
}
def _get_reward_eval_context(self, reward, reward_lines):
return {
"env": self.env,
"user": self.env.user,
"origin_line": self,
"reward": reward,
"reward_line": reward_lines[:1],
"reward_lines": reward_lines,
"float_compare": float_compare,
}

_get_reward_eval_context now receives the reward and only its own lines. Before, reward_lines contained the lines of all the rewards linked to the origin line, so with two programs a formula could see the gift lines of the other one. The view already documents it as "The Reward lines".

It is also needed by the new _compute_margin, which evaluates each formula once per reward, with the lines of that reward.

3 changes: 3 additions & 0 deletions sale_loyalty_margin_computation/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
Loading
Loading