From 8bbe02d8b99530022514ba1c3b3bc912a387a899 Mon Sep 17 00:00:00 2001 From: Eduardo de Miguel Date: Tue, 20 Jan 2026 11:46:36 +0100 Subject: [PATCH] [ADD] sale_price_compliance --- sale_price_compliance/README.rst | 282 +++++++++ sale_price_compliance/__init__.py | 3 + sale_price_compliance/__manifest__.py | 35 + sale_price_compliance/hooks.py | 28 + sale_price_compliance/i18n/es.po | 325 ++++++++++ .../i18n/sale_price_compliance.pot | 322 ++++++++++ sale_price_compliance/models/__init__.py | 9 + ...price_compliance_threshold_config_mixin.py | 123 ++++ .../price_compliance_threshold_tier_mixin.py | 90 +++ .../models/product_category.py | 15 + .../models/product_product.py | 15 + .../models/product_template.py | 15 + sale_price_compliance/models/res_company.py | 9 + .../models/res_config_settings.py | 31 + sale_price_compliance/models/sale_order.py | 40 ++ .../models/sale_order_line.py | 206 ++++++ sale_price_compliance/pyproject.toml | 3 + sale_price_compliance/readme/CONTEXT.md | 5 + sale_price_compliance/readme/CONTRIBUTORS.md | 1 + sale_price_compliance/readme/DESCRIPTION.md | 43 ++ sale_price_compliance/readme/ROADMAP.md | 1 + sale_price_compliance/readme/USAGE.md | 94 +++ sale_price_compliance/report/__init__.py | 1 + sale_price_compliance/report/sale_report.py | 29 + .../report/sale_report_view.xml | 31 + .../security/sale_price_compliance.xml | 12 + .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 597 ++++++++++++++++++ .../img/price_compliance_sale_line_form.png | Bin 0 -> 9903 bytes .../img/price_compliance_sale_line_kanban.png | Bin 0 -> 14509 bytes .../img/price_compliance_sale_line_list.png | Bin 0 -> 19705 bytes .../static/img/price_compliance_widget.png | Bin 0 -> 31258 bytes .../price_compliance_level_widget.esm.js | 79 +++ .../widgets/price_compliance_level_widget.xml | 119 ++++ sale_price_compliance/tests/__init__.py | 2 + .../tests/test_sale_price_compliance.py | 168 +++++ .../test_sale_price_compliance_constraints.py | 174 +++++ .../views/product_category_view.xml | 59 ++ .../views/product_product_view.xml | 42 ++ .../views/product_template_view.xml | 59 ++ .../views/res_config_settings_view.xml | 88 +++ .../views/sale_order_line_view.xml | 32 + .../views/sale_order_view.xml | 60 ++ .../odoo/addons/sale_price_compliance | 1 + setup/sale_price_compliance/setup.py | 6 + 45 files changed, 3254 insertions(+) create mode 100644 sale_price_compliance/README.rst create mode 100644 sale_price_compliance/__init__.py create mode 100644 sale_price_compliance/__manifest__.py create mode 100644 sale_price_compliance/hooks.py create mode 100644 sale_price_compliance/i18n/es.po create mode 100644 sale_price_compliance/i18n/sale_price_compliance.pot create mode 100644 sale_price_compliance/models/__init__.py create mode 100644 sale_price_compliance/models/price_compliance_threshold_config_mixin.py create mode 100644 sale_price_compliance/models/price_compliance_threshold_tier_mixin.py create mode 100644 sale_price_compliance/models/product_category.py create mode 100644 sale_price_compliance/models/product_product.py create mode 100644 sale_price_compliance/models/product_template.py create mode 100644 sale_price_compliance/models/res_company.py create mode 100644 sale_price_compliance/models/res_config_settings.py create mode 100644 sale_price_compliance/models/sale_order.py create mode 100644 sale_price_compliance/models/sale_order_line.py create mode 100644 sale_price_compliance/pyproject.toml create mode 100644 sale_price_compliance/readme/CONTEXT.md create mode 100644 sale_price_compliance/readme/CONTRIBUTORS.md create mode 100644 sale_price_compliance/readme/DESCRIPTION.md create mode 100644 sale_price_compliance/readme/ROADMAP.md create mode 100644 sale_price_compliance/readme/USAGE.md create mode 100644 sale_price_compliance/report/__init__.py create mode 100644 sale_price_compliance/report/sale_report.py create mode 100644 sale_price_compliance/report/sale_report_view.xml create mode 100644 sale_price_compliance/security/sale_price_compliance.xml create mode 100644 sale_price_compliance/static/description/icon.png create mode 100644 sale_price_compliance/static/description/index.html create mode 100644 sale_price_compliance/static/img/price_compliance_sale_line_form.png create mode 100644 sale_price_compliance/static/img/price_compliance_sale_line_kanban.png create mode 100644 sale_price_compliance/static/img/price_compliance_sale_line_list.png create mode 100644 sale_price_compliance/static/img/price_compliance_widget.png create mode 100644 sale_price_compliance/static/src/widgets/price_compliance_level_widget.esm.js create mode 100644 sale_price_compliance/static/src/widgets/price_compliance_level_widget.xml create mode 100644 sale_price_compliance/tests/__init__.py create mode 100644 sale_price_compliance/tests/test_sale_price_compliance.py create mode 100644 sale_price_compliance/tests/test_sale_price_compliance_constraints.py create mode 100644 sale_price_compliance/views/product_category_view.xml create mode 100644 sale_price_compliance/views/product_product_view.xml create mode 100644 sale_price_compliance/views/product_template_view.xml create mode 100644 sale_price_compliance/views/res_config_settings_view.xml create mode 100644 sale_price_compliance/views/sale_order_line_view.xml create mode 100644 sale_price_compliance/views/sale_order_view.xml create mode 120000 setup/sale_price_compliance/odoo/addons/sale_price_compliance create mode 100644 setup/sale_price_compliance/setup.py diff --git a/sale_price_compliance/README.rst b/sale_price_compliance/README.rst new file mode 100644 index 00000000000..a03f1f6bc80 --- /dev/null +++ b/sale_price_compliance/README.rst @@ -0,0 +1,282 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +===================== +Sale Price Compliance +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f260e276fb4e11d03fc4654e03277b5e5d55090c447b6619ead20cc260e5687c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-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%2Fsale--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/sale-workflow/tree/16.0/sale_price_compliance + :alt: OCA/sale-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sale-workflow-16-0/sale-workflow-16-0-sale_price_compliance + :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/sale-workflow&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the sales pricing functionality to display a color +code based on the price at which products are being sold. + +Managing Price Compliance Thresholds can be done by adding *Manage Price +Compliance* group to any user. Compliance Tiers can be filtered on sale +reports. + +You can use up to 3 different price compliance tiers for products, +categories or for all company. + +You can customize the texts and icons of the tiers via the System +Parameters. + +Labels of the tier fields will change according to +``sale_price_compliance.price_compliance_selection_tiers_text`` System +Parameter in views. + +This functionality only applies to Sales. + +A sale with a line with Non Compliant price (doesn't fit in any defined +tiers) can't be confirmed. Only Sale Administrators can validate this +sales and a message will be posted. + +Price Compliance thresholds are selected in this order: Product > +Product Category > Company + +Each Tier represents the maximun discount applied to the Price of the +Product to achieve the target tier. + +**Color Compliance Tiers** + +- Tier 1 🟩: High-yield (Fully compliant) +- Tier 2 🟨: Medium-yield (Moderately compliant) +- Tier 3 🟧: Low-yield (Low compliant) +- Non Compliant 🟥: Non Compliant price (blocked) +- Pricelist 🟦: Pricelist has been used and it's price is between + pricelist and last compliant tier. + +**Information Display on Price Compliant Tiers** Includes a popup to +display detailed information about the price ranges within each price +compliance tiers. + +Also displays Product Base UoM and Product List Price in Sale Currency +in the Top Right corner of the Popup. + +|Price Compliance Widget| + +|Price Compliance Sale Line Form| + +|Price Compliance Sale Line List| + +|Price Compliance Sale Line Kanban| + +.. |Price Compliance Widget| image:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_price_compliance/static/img/price_compliance_widget.png +.. |Price Compliance Sale Line Form| image:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_price_compliance/static/img/price_compliance_sale_line_form.png +.. |Price Compliance Sale Line List| image:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_price_compliance/static/img/price_compliance_sale_line_list.png +.. |Price Compliance Sale Line Kanban| image:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_price_compliance/static/img/price_compliance_sale_line_kanban.png + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +This module was developed because sometimes commercial users are +incentivized to sell at prices that are more beneficial to the company. + +It will be useful if you want to visually show your commercial users +whether the price they are selling at is in line with the company's +pricing policy. + +Usage +===== + +To use this module, you need to: + +**Configure Parameter Text: Change the default price compliance tiers +texts** + +1. Go to System Parameters. +2. Create a new parameter with key + ``sale_price_compliance.price_compliance_selection_tiers_text``. +3. Write a *dictionary* with the values for tiers that you want to + change it's default text. +4. Save the parameter. +5. Create a new parameter with key + ``sale_price_compliance.price_compliance_selection_tiers_icon``. +6. Write a *dictionary* with the values for tiers that you want to + change it's default icon. +7. Save the parameter. + +Example: + +Param *sale_price_compliance.price_compliance_selection_tiers_text*: + +{'t1': 'Gold', 't2': 'Silver', 't3': 'Bronze', 'pricelist': 'Draw'} + +Param *sale_price_compliance.price_compliance_selection_tiers_icon*: + +{'t1': '🥇', 't2': '🥈', 't3': '🥉', 'non_compliant': '⛔️', 'pricelist': +'🤝'} + +Resulting texts and icons will be: + +- Gold: 🥇 +- Silver: 🥈 +- Bronze: 🥉 +- Non Compliant: ⛔️ +- Draw: 🤝 + +Available keys to be used in the dictionary are: + +- t1: Tier 1 +- t2: Tier 2 +- t3: Tier 3 +- non_compliant: Non Compliant +- pricelist: Pricelist + +**Configure: Product Price Compliance Thresholds configuration** + +1. Go to Sales > Products > Products > Select one > Sales tab +2. Enable Use Price Compliance Thresholds under Sale Price Compliance + Thresholds +3. Fill from 1 to 3 tiers that you want to use in this product. + +Example: Tier 1: 10%, Tier 2: 20%, Tier 3: 30%. (Use all tiers) + +**Configure: Product Category Price Compliance Thresholds +configuration** + +1. Go to Sales > Configuration > Products > Product Categories > Select + one +2. Enable Use Price Compliance Thresholds under Sale Price Compliance + Thresholds +3. Fill from 1 to 3 tiers that you want to use in this category. + +Example: Tier 1: 15%, Tier 2: 25%, Tier 3: 0%. (Don't use tier 3) + +**Configure: Company Price Compliance Thresholds configuration** + +1. Go to Sales > Configuration > Settings +2. Enable Use Price Compliance Thresholds under Pricing section +3. Fill from 1 to 3 tiers that you want to use for the company. + +Example: Tier 1: 30%, Tier 2: 0%, Tier 3: 0%. (Don't use tier 2 and 3) + +**Sale: As a Salesman user** + +1. Create a new sale and fill contact field +2. Add a new line with a product that has been configured to use Price + Compliance +3. Click on the 🟩, 🟨, 🟧, 🟥 or 🟦 icon at the start of the line. +4. You will see a popup with useful information about the Tier ranges. +5. Play with it and then set a Non Compliant price (change price or + discount to achieve this). +6. Try to confirm the Sale and see the error. + +**Sale: As a Sales Administrator** + +1. Confirm the previous Sale. +2. See the message on the chatter. + +**Reporting: Sales report** + +1. Go to Sales > Reporting > Sales +2. Select bar chart or Pivot view +3. Group by Price Compliance Level + +**Reporting: Salesperson report** + +1. Go to Sales > Reporting > Salesperson +2. Select Bar chart + stacked or Pivot view +3. Group by Price Compliance Level + +**Reporting: Product report** + +1. Go to Sales > Reporting > Product +2. Select Pie chart + stacked or Pivot view +3. Group by Price Compliance Level + +**Reporting: Customers report** + +1. Go to Sales > Reporting > Customers +2. Select Bar chart + stacked or Pivot view +3. Group by Price Compliance Level + +Known issues / Roadmap +====================== + +- Widget is not shown properly if the order is not saved and refreshed. + +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 +------- + +* Moduon + +Contributors +------------ + +- Eduardo de Miguel (`Moduon `__) + +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. + +.. |maintainer-Shide| image:: https://github.com/Shide.png?size=40px + :target: https://github.com/Shide + :alt: Shide +.. |maintainer-rafaelbn| image:: https://github.com/rafaelbn.png?size=40px + :target: https://github.com/rafaelbn + :alt: rafaelbn + +Current `maintainers `__: + +|maintainer-Shide| |maintainer-rafaelbn| + +This module is part of the `OCA/sale-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_price_compliance/__init__.py b/sale_price_compliance/__init__.py new file mode 100644 index 00000000000..616f71634de --- /dev/null +++ b/sale_price_compliance/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import report +from .hooks import pre_init_hook diff --git a/sale_price_compliance/__manifest__.py b/sale_price_compliance/__manifest__.py new file mode 100644 index 00000000000..e36aa08387c --- /dev/null +++ b/sale_price_compliance/__manifest__.py @@ -0,0 +1,35 @@ +# Copyright 2025 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +{ + "name": "Sale Price Compliance", + "summary": "Visual price compliance based on product, category and company thresholds", + "version": "16.0.1.0.0", + "development_status": "Alpha", + "category": "Sales/Sales", + "website": "https://github.com/OCA/sale-workflow", + "author": "Moduon, Odoo Community Association (OCA)", + "maintainers": ["Shide", "rafaelbn"], + "license": "LGPL-3", + "application": False, + "installable": True, + "depends": [ + "sale", + ], + "data": [ + "security/sale_price_compliance.xml", + "views/res_config_settings_view.xml", + "views/product_category_view.xml", + "views/product_template_view.xml", + "views/product_product_view.xml", + "views/sale_order_line_view.xml", + "views/sale_order_view.xml", + "report/sale_report_view.xml", + ], + "pre_init_hook": "pre_init_hook", + "assets": { + "web.assets_backend": [ + "sale_price_compliance/static/src/**/*", + ], + }, +} diff --git a/sale_price_compliance/hooks.py b/sale_price_compliance/hooks.py new file mode 100644 index 00000000000..50204722c47 --- /dev/null +++ b/sale_price_compliance/hooks.py @@ -0,0 +1,28 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +import logging + +from odoo.tools import sql + +logger = logging.getLogger(__name__) + + +def pre_init_hook(cr): + """Create table columns for computed fields to not get them computed by Odoo.""" + if not sql.column_exists(cr, "sale_order_line", "price_compliance_tier"): + sql.create_column( + cr, + "sale_order_line", + "price_compliance_tier", + "VARCHAR", + comment="Price Compliance Tier", + ) + if not sql.column_exists(cr, "sale_order_line", "price_compliance_data"): + sql.create_column( + cr, + "sale_order_line", + "price_compliance_data", + "JSONB", + comment="Price Compliance Data", + ) diff --git a/sale_price_compliance/i18n/es.po b/sale_price_compliance/i18n/es.po new file mode 100644 index 00000000000..6c28a9d7d16 --- /dev/null +++ b/sale_price_compliance/i18n/es.po @@ -0,0 +1,325 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_price_compliance +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-02-19 11:09+0000\n" +"PO-Revision-Date: 2026-02-19 12:10+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: 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.6\n" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "" +"" +msgstr "" +"" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "(High-yield: Fully compliant)" +msgstr "(Alta Rentabilidad: Totalmente conforme)" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "(Low-yield: Low compliant)" +msgstr "(Baja Rentabilidad: Cumplimiento mínimo)" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "(Medium-yield: Moderately compliant)" +msgstr "(Rentabilidad Media: Moderadamente conforme)" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_res_company +msgid "Companies" +msgstr "Compañías" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_res_config_settings +msgid "Config Settings" +msgstr "Ajustes de configuración" + +#. module: sale_price_compliance +#. odoo-javascript +#: code:addons/sale_price_compliance/static/src/widgets/price_compliance_level_widget.xml:0 +#, python-format +msgid "Discount" +msgstr "Descuento" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "" +"Enable Default Price Compliance Thresholds for the Company.\n" +" Prices under last filled Tier are considered non-compliant and will be blocked." +msgstr "" +"Habilitar umbrales de cumplimiento de precios por defecto para la compañía.\n" +" Los precios por debajo del último Nivel completado se consideran no conformes y serán bloqueados." + +#. module: sale_price_compliance +#: model:ir.model.fields,help:sale_price_compliance.field_sale_order_line__price_compliance_data +msgid "Holds additional data related to price compliance calculations." +msgstr "Contiene datos adicionales relacionados con los cálculos de cumplimiento de precios." + +#. module: sale_price_compliance +#: model:ir.model.fields,help:sale_price_compliance.field_product_price_compliance_threshold_tier_mixin__price_compliance_tier +#: model:ir.model.fields,help:sale_price_compliance.field_sale_order_line__price_compliance_tier +#: model:ir.model.fields,help:sale_price_compliance.field_sale_report__price_compliance_tier +msgid "Indicates the Tier of Price Compliance based on the unit price and applied discount compared to defined thresholds." +msgstr "" +"Indica el Nivel de cumplimiento de precios basado en el precio unitario y el descuento aplicado en comparación con los " +"umbrales definidos." + +#. module: sale_price_compliance +#: model:res.groups,name:sale_price_compliance.price_compliance_threshold_manager +msgid "Manage Price Compliance" +msgstr "Gestionar el cumplimiento de precios" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py:0 +#, python-format +msgid "Non Compliant" +msgstr "No Conforme" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/sale_order.py:0 +#, python-format +msgid "Order confirmed with Non Compliant prices by %s." +msgstr "Pedido confirmado con precios No Conformes por %s." + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.sale_report_view_tree +msgid "PCL" +msgstr "NCP" + +#. module: sale_price_compliance +#: model:ir.model.fields,field_description:sale_price_compliance.field_sale_order_line__price_compliance_data +msgid "Price Compliance Data" +msgstr "Datos de cumplimiento de precios" + +#. module: sale_price_compliance +#. odoo-javascript +#: code:addons/sale_price_compliance/static/src/widgets/price_compliance_level_widget.xml:0 +#, python-format +msgid "Price Compliance Thresholds" +msgstr "Umbrales de cumplimiento de precios" + +#. module: sale_price_compliance +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_category_check_price_compliance_le_1 +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_price_compliance_threshold_config_mixin_check_price_compliance_le_1 +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_product_check_price_compliance_le_1 +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_template_check_price_compliance_le_1 +#: model:ir.model.constraint,message:sale_price_compliance.constraint_res_company_check_price_compliance_le_1 +msgid "Price Compliance Thresholds Percentage should be lower or equal to 100%." +msgstr "El porcentaje de los umbrales de cumplimiento de precios no puede ser superior al 100%." + +#. module: sale_price_compliance +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_category_check_price_compliance_positive +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_price_compliance_threshold_config_mixin_check_price_compliance_positive +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_product_check_price_compliance_positive +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_template_check_price_compliance_positive +#: model:ir.model.constraint,message:sale_price_compliance.constraint_res_company_check_price_compliance_positive +msgid "Price Compliance Thresholds Percentage should be positive." +msgstr "El porcentaje de los umbrales de cumplimiento de precios debe ser positivo." + +#. module: sale_price_compliance +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_category_check_price_compliance_no_gaps +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_price_compliance_threshold_config_mixin_check_price_compliance_no_gaps +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_product_check_price_compliance_no_gaps +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_template_check_price_compliance_no_gaps +#: model:ir.model.constraint,message:sale_price_compliance.constraint_res_company_check_price_compliance_no_gaps +msgid "Price Compliance Thresholds should not have gaps." +msgstr "Los umbrales de cumplimiento de precios no deben tener saltos." + +#. module: sale_price_compliance +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_price_compliance_threshold_tier_mixin__price_compliance_tier +#: model:ir.model.fields,field_description:sale_price_compliance.field_sale_order_line__price_compliance_tier +#: model:ir.model.fields,field_description:sale_price_compliance.field_sale_report__price_compliance_tier +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.view_order_form +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.view_order_product_search +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.view_sales_order_line_filter +msgid "Price Compliance Tier" +msgstr "Nivel de cumplimiento de precios" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py:0 +#, python-format +msgid "Pricelist" +msgstr "Tarifa" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_product_template +msgid "Product" +msgstr "Producto" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_product_category +msgid "Product Category" +msgstr "Categoría de producto" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_product_price_compliance_threshold_config_mixin +msgid "Product Price Compliance Threshold Config Mixin" +msgstr "Mixin de configuración del umbral de cumplimiento de precios de producto" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_product_price_compliance_threshold_tier_mixin +msgid "Product Price Compliance Threshold Tier Mixin" +msgstr "Mixin de niveles del umbral de cumplimiento de precios de producto" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_product_product +msgid "Product Variant" +msgstr "Variante de producto" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.product_category_form_view +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.product_template_form_view +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.product_variant_easy_edit_view +msgid "Sale Price Compliance Thresholds" +msgstr "Umbrales de cumplimiento de precios de venta" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_sale_report +msgid "Sales Analysis Report" +msgstr "Informe de análisis de ventas" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_sale_order +msgid "Sales Order" +msgstr "Órdenes de Venta" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_sale_order_line +msgid "Sales Order Line" +msgstr "Línea de pedido de ventas" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/sale_order.py:0 +#, python-format +msgid "" +"The order contains lines with non-compliant prices.\n" +"Please review the prices before confirming the order or contact your sales manager for further assistance." +msgstr "" +"El pedido contiene líneas con precios no conformes.\n" +"Por favor, revise los precios antes de confirmar el pedido o contacte con su responsable de ventas para obtener ayuda." + +#. module: sale_price_compliance +#: model:res.groups,comment:sale_price_compliance.price_compliance_threshold_manager +msgid "The user will be able to manage Price Compliance Thresholds." +msgstr "El usuario será capaz de gestionar los umbrales de cumplimiento de precios." + +#. module: sale_price_compliance +#: model:ir.model.fields,help:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t1 +msgid "Threshold for Tier 1 Price Compliance (e.g., High-yield). Prices below this tier are considered fully compliant." +msgstr "" +"Umbral para el Tramo 1 de cumplimiento de precios (ej. Alta Rentabilidad). Los precios por debajo de este nivel se " +"consideran totalmente conformes." + +#. module: sale_price_compliance +#: model:ir.model.fields,help:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t2 +msgid "" +"Threshold for Tier 2 Price Compliance (e.g., Medium-yield). Prices between Tier 1 and Tier 2 are considered moderately " +"compliant." +msgstr "" +"Umbral para el Tramo 2 de cumplimiento de precios (ej. Rentabilidad Media). Los precios entre el Nivel 1 y el Nivel 2 se " +"consideran moderadamente conformes." + +#. module: sale_price_compliance +#: model:ir.model.fields,help:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t3 +msgid "Threshold for Tier 3 Price Compliance (e.g., Low-yield). Prices between Tier 2 and Tier 3 are considered low compliant." +msgstr "" +"Umbral para el Tramo 3 de cumplimiento de precios (ej. Baja Rentabilidad). Los precios entre el Nivel 2 y el Nivel 3 se " +"consideran de cumplimiento mínimo." + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py:0 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_category__price_compliance_threshold_t1 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_price_compliance_threshold_config_mixin__price_compliance_threshold_t1 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_product__price_compliance_threshold_t1 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_template__price_compliance_threshold_t1 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_company__price_compliance_threshold_t1 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t1 +#, python-format +msgid "Tier 1" +msgstr "Tramo 1" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "Tier 1 🟩" +msgstr "Tramo 1 🟩" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py:0 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_category__price_compliance_threshold_t2 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_price_compliance_threshold_config_mixin__price_compliance_threshold_t2 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_product__price_compliance_threshold_t2 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_template__price_compliance_threshold_t2 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_company__price_compliance_threshold_t2 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t2 +#, python-format +msgid "Tier 2" +msgstr "Tramo 2" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "Tier 2 🟨" +msgstr "Tramo 2 🟨" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py:0 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_category__price_compliance_threshold_t3 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_price_compliance_threshold_config_mixin__price_compliance_threshold_t3 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_product__price_compliance_threshold_t3 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_template__price_compliance_threshold_t3 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_company__price_compliance_threshold_t3 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t3 +#, python-format +msgid "Tier 3" +msgstr "Tramo 3" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "Tier 3 🟧" +msgstr "Tramo 3 🟧" + +#. module: sale_price_compliance +#. odoo-javascript +#: code:addons/sale_price_compliance/static/src/widgets/price_compliance_level_widget.xml:0 +#, python-format +msgid "Unit Price" +msgstr "Precio unitario" + +#. module: sale_price_compliance +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_category__use_price_compliance_threshold +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_price_compliance_threshold_config_mixin__use_price_compliance_threshold +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_product__use_price_compliance_threshold +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_template__use_price_compliance_threshold +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_company__use_price_compliance_threshold +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_config_settings__use_price_compliance_threshold +msgid "Use Price Compliance Thresholds" +msgstr "Usar umbrales de cumplimiento de precios" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.product_category_search_view +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.product_template_search_view +msgid "Uses Price Compliant Threshold" +msgstr "Usa umbral de cumplimiento de precios" diff --git a/sale_price_compliance/i18n/sale_price_compliance.pot b/sale_price_compliance/i18n/sale_price_compliance.pot new file mode 100644 index 00000000000..36b1207f669 --- /dev/null +++ b/sale_price_compliance/i18n/sale_price_compliance.pot @@ -0,0 +1,322 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_price_compliance +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-02-19 11:09+0000\n" +"PO-Revision-Date: 2026-02-19 11:09+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_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "" +"" +msgstr "" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "" +"(High-yield: Fully " +"compliant)" +msgstr "" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "" +"(Low-yield: Low compliant)" +msgstr "" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "" +"(Medium-yield: Moderately " +"compliant)" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_res_company +msgid "Companies" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: sale_price_compliance +#. odoo-javascript +#: code:addons/sale_price_compliance/static/src/widgets/price_compliance_level_widget.xml:0 +#, python-format +msgid "Discount" +msgstr "" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "" +"Enable Default Price Compliance Thresholds for the Company.\n" +" Prices under last filled Tier are considered non-compliant and will be blocked." +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.fields,help:sale_price_compliance.field_sale_order_line__price_compliance_data +msgid "Holds additional data related to price compliance calculations." +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.fields,help:sale_price_compliance.field_product_price_compliance_threshold_tier_mixin__price_compliance_tier +#: model:ir.model.fields,help:sale_price_compliance.field_sale_order_line__price_compliance_tier +#: model:ir.model.fields,help:sale_price_compliance.field_sale_report__price_compliance_tier +msgid "" +"Indicates the Tier of Price Compliance based on the unit price and applied " +"discount compared to defined thresholds." +msgstr "" + +#. module: sale_price_compliance +#: model:res.groups,name:sale_price_compliance.price_compliance_threshold_manager +msgid "Manage Price Compliance" +msgstr "" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py:0 +#, python-format +msgid "Non Compliant" +msgstr "" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/sale_order.py:0 +#, python-format +msgid "Order confirmed with Non Compliant prices by %s." +msgstr "" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.sale_report_view_tree +msgid "PCL" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.fields,field_description:sale_price_compliance.field_sale_order_line__price_compliance_data +msgid "Price Compliance Data" +msgstr "" + +#. module: sale_price_compliance +#. odoo-javascript +#: code:addons/sale_price_compliance/static/src/widgets/price_compliance_level_widget.xml:0 +#, python-format +msgid "Price Compliance Thresholds" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_category_check_price_compliance_le_1 +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_price_compliance_threshold_config_mixin_check_price_compliance_le_1 +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_product_check_price_compliance_le_1 +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_template_check_price_compliance_le_1 +#: model:ir.model.constraint,message:sale_price_compliance.constraint_res_company_check_price_compliance_le_1 +msgid "" +"Price Compliance Thresholds Percentage should be lower or equal to 100%." +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_category_check_price_compliance_positive +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_price_compliance_threshold_config_mixin_check_price_compliance_positive +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_product_check_price_compliance_positive +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_template_check_price_compliance_positive +#: model:ir.model.constraint,message:sale_price_compliance.constraint_res_company_check_price_compliance_positive +msgid "Price Compliance Thresholds Percentage should be positive." +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_category_check_price_compliance_no_gaps +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_price_compliance_threshold_config_mixin_check_price_compliance_no_gaps +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_product_check_price_compliance_no_gaps +#: model:ir.model.constraint,message:sale_price_compliance.constraint_product_template_check_price_compliance_no_gaps +#: model:ir.model.constraint,message:sale_price_compliance.constraint_res_company_check_price_compliance_no_gaps +msgid "Price Compliance Thresholds should not have gaps." +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_price_compliance_threshold_tier_mixin__price_compliance_tier +#: model:ir.model.fields,field_description:sale_price_compliance.field_sale_order_line__price_compliance_tier +#: model:ir.model.fields,field_description:sale_price_compliance.field_sale_report__price_compliance_tier +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.view_order_form +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.view_order_product_search +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.view_sales_order_line_filter +msgid "Price Compliance Tier" +msgstr "" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py:0 +#, python-format +msgid "Pricelist" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_product_template +msgid "Product" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_product_category +msgid "Product Category" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_product_price_compliance_threshold_config_mixin +msgid "Product Price Compliance Threshold Config Mixin" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_product_price_compliance_threshold_tier_mixin +msgid "Product Price Compliance Threshold Tier Mixin" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_product_product +msgid "Product Variant" +msgstr "" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.product_category_form_view +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.product_template_form_view +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.product_variant_easy_edit_view +msgid "Sale Price Compliance Thresholds" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_sale_report +msgid "Sales Analysis Report" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model,name:sale_price_compliance.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/sale_order.py:0 +#, python-format +msgid "" +"The order contains lines with non-compliant prices.\n" +"Please review the prices before confirming the order or contact your sales manager for further assistance." +msgstr "" + +#. module: sale_price_compliance +#: model:res.groups,comment:sale_price_compliance.price_compliance_threshold_manager +msgid "The user will be able to manage Price Compliance Thresholds." +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.fields,help:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t1 +msgid "" +"Threshold for Tier 1 Price Compliance (e.g., High-yield). Prices below this " +"tier are considered fully compliant." +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.fields,help:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t2 +msgid "" +"Threshold for Tier 2 Price Compliance (e.g., Medium-yield). Prices between " +"Tier 1 and Tier 2 are considered moderately compliant." +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.fields,help:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t3 +msgid "" +"Threshold for Tier 3 Price Compliance (e.g., Low-yield). Prices between Tier" +" 2 and Tier 3 are considered low compliant." +msgstr "" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py:0 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_category__price_compliance_threshold_t1 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_price_compliance_threshold_config_mixin__price_compliance_threshold_t1 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_product__price_compliance_threshold_t1 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_template__price_compliance_threshold_t1 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_company__price_compliance_threshold_t1 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t1 +#, python-format +msgid "Tier 1" +msgstr "" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "Tier 1 🟩" +msgstr "" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py:0 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_category__price_compliance_threshold_t2 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_price_compliance_threshold_config_mixin__price_compliance_threshold_t2 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_product__price_compliance_threshold_t2 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_template__price_compliance_threshold_t2 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_company__price_compliance_threshold_t2 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t2 +#, python-format +msgid "Tier 2" +msgstr "" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "Tier 2 🟨" +msgstr "" + +#. module: sale_price_compliance +#. odoo-python +#: code:addons/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py:0 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_category__price_compliance_threshold_t3 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_price_compliance_threshold_config_mixin__price_compliance_threshold_t3 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_product__price_compliance_threshold_t3 +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_template__price_compliance_threshold_t3 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_company__price_compliance_threshold_t3 +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_config_settings__price_compliance_threshold_t3 +#, python-format +msgid "Tier 3" +msgstr "" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.res_config_settings_view_form +msgid "Tier 3 🟧" +msgstr "" + +#. module: sale_price_compliance +#. odoo-javascript +#: code:addons/sale_price_compliance/static/src/widgets/price_compliance_level_widget.xml:0 +#, python-format +msgid "Unit Price" +msgstr "" + +#. module: sale_price_compliance +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_category__use_price_compliance_threshold +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_price_compliance_threshold_config_mixin__use_price_compliance_threshold +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_product__use_price_compliance_threshold +#: model:ir.model.fields,field_description:sale_price_compliance.field_product_template__use_price_compliance_threshold +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_company__use_price_compliance_threshold +#: model:ir.model.fields,field_description:sale_price_compliance.field_res_config_settings__use_price_compliance_threshold +msgid "Use Price Compliance Thresholds" +msgstr "" + +#. module: sale_price_compliance +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.product_category_search_view +#: model_terms:ir.ui.view,arch_db:sale_price_compliance.product_template_search_view +msgid "Uses Price Compliant Threshold" +msgstr "" diff --git a/sale_price_compliance/models/__init__.py b/sale_price_compliance/models/__init__.py new file mode 100644 index 00000000000..521fef89045 --- /dev/null +++ b/sale_price_compliance/models/__init__.py @@ -0,0 +1,9 @@ +from . import price_compliance_threshold_config_mixin +from . import price_compliance_threshold_tier_mixin +from . import res_company +from . import res_config_settings +from . import product_category +from . import product_product +from . import product_template +from . import sale_order_line +from . import sale_order diff --git a/sale_price_compliance/models/price_compliance_threshold_config_mixin.py b/sale_price_compliance/models/price_compliance_threshold_config_mixin.py new file mode 100644 index 00000000000..555f742ad04 --- /dev/null +++ b/sale_price_compliance/models/price_compliance_threshold_config_mixin.py @@ -0,0 +1,123 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from lxml import etree + +from odoo import api, fields, models + + +class PriceComplianceThresholdConfigMixin(models.AbstractModel): + _name = "product.price.compliance.threshold.config.mixin" + _description = "Product Price Compliance Threshold Config Mixin" + + use_price_compliance_threshold = fields.Boolean( + string="Use Price Compliance Thresholds", + ) + price_compliance_threshold_t1 = fields.Float( + string="Tier 1", + default=0.0, + digits="Discount", + ) + price_compliance_threshold_t2 = fields.Float( + string="Tier 2", + default=0.0, + digits="Discount", + ) + price_compliance_threshold_t3 = fields.Float( + string="Tier 3", + default=0.0, + digits="Discount", + ) + + _sql_constraints = [ + ( + "check_price_compliance_positive", + """CHECK ( + use_price_compliance_threshold IS NOT TRUE + OR ( + price_compliance_threshold_t1 >= 0.0 AND + price_compliance_threshold_t2 >= 0.0 AND + price_compliance_threshold_t3 >= 0.0 + ) + )""", + "Price Compliance Thresholds Percentage should be positive.", + ), + ( + "check_price_compliance_le_1", + """CHECK ( + use_price_compliance_threshold IS NOT TRUE + OR ( + price_compliance_threshold_t1 <= 1.0 AND + price_compliance_threshold_t2 <= 1.0 AND + price_compliance_threshold_t3 <= 1.0 + ) + )""", + "Price Compliance Thresholds Percentage should be lower or equal to 100%.", + ), + ( + "check_price_compliance_no_gaps", + """CHECK ( + use_price_compliance_threshold IS NOT TRUE + OR ( + ( + price_compliance_threshold_t1 > 0.0 OR + price_compliance_threshold_t2 = 0.0 + ) + AND + ( + price_compliance_threshold_t2 > 0.0 OR + price_compliance_threshold_t3 = 0.0 + ) + ) + )""", + "Price Compliance Thresholds should not have gaps.", + ), + ] + + def _get_price_compliance_thresholds(self): + """Returns price compliance thresholds""" + self.ensure_one() + if not self.use_price_compliance_threshold: + return [] + # Check thresholds in order and stop at the first missing one + used_threshold_tiers = [] + for threshold in [ + self.price_compliance_threshold_t1, + self.price_compliance_threshold_t2, + self.price_compliance_threshold_t3, + ]: + if not threshold: + break + used_threshold_tiers.append(threshold) + return used_threshold_tiers + + @api.model + def get_view(self, view_id=None, view_type="form", **options): + """Replaces price_compliance_threshold_t[1,2,3] labels based on tier text + definitions from sale.order.line, which can be customized via system + parameters.""" + result = super().get_view(view_id=view_id, view_type=view_type, **options) + # Check if parameter has been created + if ( + not self.env["ir.config_parameter"] + .sudo() + .get_param( + "sale_price_compliance.price_compliance_selection_tiers_text", + default=False, + ) + ): + return result + # Get tier text selection from sale.order.line (only t1, t2, t3) + tiers_selection = self.env[ + "product.price.compliance.threshold.tier.mixin" + ]._get_price_compliance_selection_tiers_text()[:3] + # Update field labels in XML + doc = etree.XML(result["arch"]) + for tier_key, tier_text in tiers_selection: + field_name = f"price_compliance_threshold_{tier_key}" + for node in doc.xpath(f"//field[@name='{field_name}']"): + node.set("string", tier_text) + if "fields" in result and field_name in result["fields"]: + result["fields"][field_name]["string"] = tier_text + result["arch"] = etree.tostring(doc, encoding="unicode") + return result diff --git a/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py b/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py new file mode 100644 index 00000000000..3be7956d63c --- /dev/null +++ b/sale_price_compliance/models/price_compliance_threshold_tier_mixin.py @@ -0,0 +1,90 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +import logging + +from odoo import _, api, fields, models +from odoo.tools.safe_eval import safe_eval + +_logger = logging.getLogger(__name__) + + +class PriceComplianceThresholdTierMixin(models.AbstractModel): + _name = "product.price.compliance.threshold.tier.mixin" + _description = "Product Price Compliance Threshold Tier Mixin" + + price_compliance_tier = fields.Selection( + selection="_get_price_compliance_selection_tiers", + readonly=True, + help="Indicates the Tier of Price Compliance based on the unit price and " + "applied discount compared to defined thresholds.", + ) + + @api.model + def _get_price_compliance_selection_tiers(self): + """Price compliance selection tiers with icons. + + Override this method to provide custom icons or define any + other logic (visual impairments for example) + """ + return self._get_price_compliance_selection_tiers_icon_color() + + @api.model + def _get_tier_selections(self, param, default_selection): + """Get tier selections using config parameters""" + icp_st = self.env["ir.config_parameter"].sudo().get_param(param) + try: + st_dict = safe_eval(icp_st) + except Exception: + _logger.warning("Wrong parameter value for %s", param) + return default_selection + if st_dict and isinstance(st_dict, dict): + return [ + ("t1", st_dict.get("t1", default_selection[0][1])), + ("t2", st_dict.get("t2", default_selection[1][1])), + ("t3", st_dict.get("t3", default_selection[2][1])), + ( + "non_compliant", + st_dict.get("non_compliant", default_selection[3][1]), + ), + ("pricelist", st_dict.get("pricelist", default_selection[4][1])), + ] + return default_selection + + @api.model + def _get_price_compliance_selection_tiers_icon_color(self): + """Default Price Compliance tiers with icon colors""" + return self._get_tier_selections( + "sale_price_compliance.price_compliance_selection_tiers_icon", + self._get_price_compliance_selection_tiers_icon_color_default(), + ) + + @api.model + def _get_price_compliance_selection_tiers_text(self): + """Price Compliance selection tiers on text""" + return self._get_tier_selections( + "sale_price_compliance.price_compliance_selection_tiers_text", + self._get_price_compliance_selection_tiers_text_default(), + ) + + @api.model + def _get_price_compliance_selection_tiers_icon_color_default(self): + """Default Price Compliance tiers with icon colors (default)""" + return [ + ("t1", "🟩"), + ("t2", "🟨"), + ("t3", "🟧"), + ("non_compliant", "🟥"), + ("pricelist", "🟦"), + ] + + @api.model + def _get_price_compliance_selection_tiers_text_default(self): + """Price Compliance selection tiers on text (default)""" + return [ + ("t1", _("Tier 1")), + ("t2", _("Tier 2")), + ("t3", _("Tier 3")), + ("non_compliant", _("Non Compliant")), + ("pricelist", _("Pricelist")), + ] diff --git a/sale_price_compliance/models/product_category.py b/sale_price_compliance/models/product_category.py new file mode 100644 index 00000000000..92f2165eae7 --- /dev/null +++ b/sale_price_compliance/models/product_category.py @@ -0,0 +1,15 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo import models + + +class ProductCategory(models.Model): + _name = "product.category" + _inherit = ["product.category", "product.price.compliance.threshold.config.mixin"] + + def _get_price_compliance_thresholds(self): + res = super()._get_price_compliance_thresholds() + if not res and self.parent_id: + res = self.parent_id._get_price_compliance_thresholds() + return res or self.env.company._get_price_compliance_thresholds() diff --git a/sale_price_compliance/models/product_product.py b/sale_price_compliance/models/product_product.py new file mode 100644 index 00000000000..ecec5bd261a --- /dev/null +++ b/sale_price_compliance/models/product_product.py @@ -0,0 +1,15 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo import models + + +class ProductProduct(models.Model): + _name = "product.product" + _inherit = ["product.product", "product.price.compliance.threshold.config.mixin"] + + def _get_price_compliance_thresholds(self): + return ( + super()._get_price_compliance_thresholds() + or self.product_tmpl_id._get_price_compliance_thresholds() + ) diff --git a/sale_price_compliance/models/product_template.py b/sale_price_compliance/models/product_template.py new file mode 100644 index 00000000000..4fb0d99e16c --- /dev/null +++ b/sale_price_compliance/models/product_template.py @@ -0,0 +1,15 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo import models + + +class ProductTemplate(models.Model): + _name = "product.template" + _inherit = ["product.template", "product.price.compliance.threshold.config.mixin"] + + def _get_price_compliance_thresholds(self): + return ( + super()._get_price_compliance_thresholds() + or self.categ_id._get_price_compliance_thresholds() + ) diff --git a/sale_price_compliance/models/res_company.py b/sale_price_compliance/models/res_company.py new file mode 100644 index 00000000000..eda0c523159 --- /dev/null +++ b/sale_price_compliance/models/res_company.py @@ -0,0 +1,9 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) +# +from odoo import models + + +class ResCompany(models.Model): + _name = "res.company" + _inherit = ["res.company", "product.price.compliance.threshold.config.mixin"] diff --git a/sale_price_compliance/models/res_config_settings.py b/sale_price_compliance/models/res_config_settings.py new file mode 100644 index 00000000000..c6d5c52e68f --- /dev/null +++ b/sale_price_compliance/models/res_config_settings.py @@ -0,0 +1,31 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) +# +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + use_price_compliance_threshold = fields.Boolean( + related="company_id.use_price_compliance_threshold", + readonly=False, + ) + price_compliance_threshold_t1 = fields.Float( + related="company_id.price_compliance_threshold_t1", + readonly=False, + help="Threshold for Tier 1 Price Compliance (e.g., High-yield). " + "Prices below this tier are considered fully compliant.", + ) + price_compliance_threshold_t2 = fields.Float( + related="company_id.price_compliance_threshold_t2", + readonly=False, + help="Threshold for Tier 2 Price Compliance (e.g., Medium-yield). " + "Prices between Tier 1 and Tier 2 are considered moderately compliant.", + ) + price_compliance_threshold_t3 = fields.Float( + related="company_id.price_compliance_threshold_t3", + readonly=False, + help="Threshold for Tier 3 Price Compliance (e.g., Low-yield). " + "Prices between Tier 2 and Tier 3 are considered low compliant.", + ) diff --git a/sale_price_compliance/models/sale_order.py b/sale_price_compliance/models/sale_order.py new file mode 100644 index 00000000000..5be8bdd19d4 --- /dev/null +++ b/sale_price_compliance/models/sale_order.py @@ -0,0 +1,40 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo import _, exceptions, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def _action_confirm(self): + res = super()._action_confirm() + self._check_compliant_pricing() + return res + + def _check_compliant_pricing(self): + """Check if lines are in compliant state""" + is_sale_manager = self.env.user.has_group("sales_team.group_sale_manager") + for record in self: + # Check all lines are in compliant state + non_compliant_lines = record.order_line.filtered_domain( + [("price_compliance_tier", "=", "non_compliant")] + ) + if not non_compliant_lines: + continue + # If user is a Sales manager, skip this check + if is_sale_manager: + record.message_post( + body=_( + "Order confirmed with Non Compliant prices by %s.", + self.env.user.name, + ) + ) + continue + raise exceptions.UserError( + _( + "The order contains lines with non-compliant prices.\n" + "Please review the prices before confirming the order or " + "contact your sales manager for further assistance." + ) + ) diff --git a/sale_price_compliance/models/sale_order_line.py b/sale_price_compliance/models/sale_order_line.py new file mode 100644 index 00000000000..41767427aa7 --- /dev/null +++ b/sale_price_compliance/models/sale_order_line.py @@ -0,0 +1,206 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +import logging + +from odoo import api, fields, models +from odoo.tools import float_compare, float_round + +_logger = logging.getLogger(__name__) + + +class SaleOrderLine(models.Model): + _name = "sale.order.line" + _inherit = ["sale.order.line", "product.price.compliance.threshold.tier.mixin"] + + price_compliance_tier = fields.Selection( + compute="_compute_price_compliance_tier", + store=True, + ) + price_compliance_data = fields.Json( + compute="_compute_price_compliance_tier", + store=True, + readonly=True, + help="Holds additional data related to price compliance calculations.", + ) + + def _get_price_compliance_thresholds(self): + """Price compliance thresholds for this sale line""" + self.ensure_one() + return self.product_id._get_price_compliance_thresholds() + + def _get_price_compliance_data(self, precision_digits): + """Gets a full dictionary with all calculated information about + price compliance for this product""" + self.ensure_one() + + def _precision_rounder(val): + return round( + float_round(val, precision_digits=precision_digits), + # Post round to ensure only display X decimals + precision_digits, + ) + + # Base price in sale order currency + base_price = self.product_id.list_price + if self.product_id.currency_id != self.currency_id: + base_price = self.product_id.currency_id._convert( + base_price, + self.currency_id, + self.company_id, + self._get_order_date(), + ) + thresholds = self._get_price_compliance_thresholds() + selection_tiers_map = dict(self._get_price_compliance_selection_tiers()) + selection_tiers_text_map = dict( + self._get_price_compliance_selection_tiers_text() + ) + common_data_values = { + "currency_symbol": self.currency_id.symbol, + "product_base_uom": self.product_id.uom_id.name, + "precision_digits": precision_digits, + "extra_description": "", + } + # Prepare data per tier + price_compliance_data = [] + for idx_tier, current_threshold_disc in enumerate(thresholds, 1): + current_threshold_price = _precision_rounder( + base_price * (1.0 - current_threshold_disc), + ) + if idx_tier == 1: + price_compliance_data.append( + { + "tier": f"t{idx_tier}", + "discount": (0.0, current_threshold_disc), + "price": (current_threshold_price, base_price), + "display": ( + selection_tiers_map[f"t{idx_tier}"], + selection_tiers_text_map[f"t{idx_tier}"], + ), + **common_data_values, + } + ) + continue + # -2 because idx_tier is 1-based + prev_threshold_disc = thresholds[idx_tier - 2] + prev_threshold_price = _precision_rounder( + base_price * (1.0 - prev_threshold_disc), + ) + price_compliance_data.append( + { + "tier": f"t{idx_tier}", + "discount": (prev_threshold_disc, current_threshold_disc), + "price": (current_threshold_price, prev_threshold_price), + "display": ( + selection_tiers_map[f"t{idx_tier}"], + selection_tiers_text_map[f"t{idx_tier}"], + ), + **common_data_values, + } + ) + if not thresholds: + # If no thresholds are defined, return empty data + return price_compliance_data + + # Add non_compliant tier + last_threshold_disc = thresholds[-1] + last_threshold_price = _precision_rounder( + base_price * (1.0 - last_threshold_disc), + ) + price_compliance_data.append( + { + "tier": "non_compliant", + "discount": (last_threshold_disc, 1.0), + "price": (0.0, last_threshold_price), + "display": ( + selection_tiers_map["non_compliant"], + selection_tiers_text_map["non_compliant"], + ), + **common_data_values, + } + ) + + # Add Pricelist pricelist item data if any + if self.pricelist_item_id: + pricelist_price = self._get_pricelist_price() + pricelist_description = self.pricelist_item_id.price + price_compliance_data.append( + { + "tier": "pricelist", + "discount": (0.0, 0.0), + "price": (pricelist_price, pricelist_price), + "display": ( + selection_tiers_map["pricelist"], + selection_tiers_text_map["pricelist"], + ), + **common_data_values, + **{"extra_description": pricelist_description}, + } + ) + return price_compliance_data + + @api.depends( + "price_unit", "product_uom", "product_id", "discount", "pricelist_item_id" + ) + def _compute_price_compliance_tier(self): + """Set price compliance tier""" + self.price_compliance_tier = False + self.price_compliance_data = None + precision_digits = self.env["decimal.precision"].precision_get("Product Price") + for line in self: + # 1. Line section/note, no product assigned + if line.display_type or not line.product_id: + continue + # 2. Prepare Widget Display + price_compliance_data = line._get_price_compliance_data(precision_digits) + line.price_compliance_data = price_compliance_data + if not price_compliance_data: + # Nothing to check. No thresholds defined for this product + continue + # 3. Get the UoM factor to convert line price to product base UoM + uom_factor = line.product_uom._compute_quantity( + qty=1.0, + to_unit=line.product_id.uom_id, + round=False, + ) + # 4. Convert line price_unit to product base UoM and apply discount + discount_line_unit_price_on_base_uom = float_round( + (line.price_unit / uom_factor) * (1 - line.discount / 100.0), + precision_digits=precision_digits, + ) + # 5. Apply standard compliance logic + # Check negative prices + if ( + float_compare( + discount_line_unit_price_on_base_uom, + 0.0, + precision_digits=precision_digits, + ) + < 0 + ): + line.price_compliance_tier = "non_compliant" + continue + # Default to t1 in case price is higher than all thresholds + compliant_tier = "t1" + for compliance_data in price_compliance_data: + min_price, max_price = compliance_data["price"] + if min_price <= discount_line_unit_price_on_base_uom <= max_price: + compliant_tier = compliance_data["tier"] + break + + # 6. Only check pricelist price equality if non compliant + if compliant_tier == "non_compliant" and line.pricelist_item_id: + # Consider pricelist compliance if the price after discount is + # betweeen pricelist price and last compliant tier price. + if ( + float_compare( + discount_line_unit_price_on_base_uom, + line._get_pricelist_price(), + precision_digits=precision_digits, + ) + >= 0 + ): + # If the final price after discount is equal to the + # pricelist price, we consider pricelist compliance. + compliant_tier = "pricelist" + line.price_compliance_tier = compliant_tier diff --git a/sale_price_compliance/pyproject.toml b/sale_price_compliance/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/sale_price_compliance/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sale_price_compliance/readme/CONTEXT.md b/sale_price_compliance/readme/CONTEXT.md new file mode 100644 index 00000000000..516cc2675e9 --- /dev/null +++ b/sale_price_compliance/readme/CONTEXT.md @@ -0,0 +1,5 @@ +This module was developed because sometimes commercial users are incentivized +to sell at prices that are more beneficial to the company. + +It will be useful if you want to visually show your commercial users +whether the price they are selling at is in line with the company's pricing policy. diff --git a/sale_price_compliance/readme/CONTRIBUTORS.md b/sale_price_compliance/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..290347f4260 --- /dev/null +++ b/sale_price_compliance/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Eduardo de Miguel ([Moduon](https://www.moduon.team/)) diff --git a/sale_price_compliance/readme/DESCRIPTION.md b/sale_price_compliance/readme/DESCRIPTION.md new file mode 100644 index 00000000000..7e6b5fd229f --- /dev/null +++ b/sale_price_compliance/readme/DESCRIPTION.md @@ -0,0 +1,43 @@ +This module extends the sales pricing functionality to display a color code +based on the price at which products are being sold. + +Managing Price Compliance Thresholds can be done by adding *Manage Price Compliance* + group to any user. +Compliance Tiers can be filtered on sale reports. + +You can use up to 3 different price compliance tiers for products, +categories or for all company. + +You can customize the texts and icons of the tiers via the System Parameters. + +Labels of the tier fields will change according to `sale_price_compliance.price_compliance_selection_tiers_text` System Parameter in views. + +This functionality only applies to Sales. + +A sale with a line with Non Compliant price (doesn't fit in any defined tiers) can't be confirmed. +Only Sale Administrators can validate this sales and a message will be posted. + +Price Compliance thresholds are selected in this order: Product > Product Category > Company + +Each Tier represents the maximun discount applied to the Price of the Product to achieve the target tier. + +**Color Compliance Tiers** +- Tier 1 🟩: High-yield (Fully compliant) +- Tier 2 🟨: Medium-yield (Moderately compliant) +- Tier 3 🟧: Low-yield (Low compliant) +- Non Compliant 🟥: Non Compliant price (blocked) +- Pricelist 🟦: Pricelist has been used and it's price is between pricelist and last compliant tier. + +**Information Display on Price Compliant Tiers** +Includes a popup to display detailed information about the price ranges +within each price compliance tiers. + +Also displays Product Base UoM and Product List Price in Sale Currency in the Top Right corner of the Popup. + +![Price Compliance Widget](../static/img/price_compliance_widget.png) + +![Price Compliance Sale Line Form](../static/img/price_compliance_sale_line_form.png) + +![Price Compliance Sale Line List](../static/img/price_compliance_sale_line_list.png) + +![Price Compliance Sale Line Kanban](../static/img/price_compliance_sale_line_kanban.png) diff --git a/sale_price_compliance/readme/ROADMAP.md b/sale_price_compliance/readme/ROADMAP.md new file mode 100644 index 00000000000..7f6123196f1 --- /dev/null +++ b/sale_price_compliance/readme/ROADMAP.md @@ -0,0 +1 @@ +- Widget is not shown properly if the order is not saved and refreshed. diff --git a/sale_price_compliance/readme/USAGE.md b/sale_price_compliance/readme/USAGE.md new file mode 100644 index 00000000000..e6a98f18f1d --- /dev/null +++ b/sale_price_compliance/readme/USAGE.md @@ -0,0 +1,94 @@ +To use this module, you need to: + +**Configure Parameter Text: Change the default price compliance tiers texts** +1. Go to System Parameters. +1. Create a new parameter with key `sale_price_compliance.price_compliance_selection_tiers_text`. +1. Write a *dictionary* with the values for tiers that you want to change it's default text. +1. Save the parameter. +1. Create a new parameter with key `sale_price_compliance.price_compliance_selection_tiers_icon`. +1. Write a *dictionary* with the values for tiers that you want to change it's default icon. +1. Save the parameter. + +Example: + +Param *sale_price_compliance.price_compliance_selection_tiers_text*: + +{'t1': 'Gold', 't2': 'Silver', 't3': 'Bronze', 'pricelist': 'Draw'} + +Param *sale_price_compliance.price_compliance_selection_tiers_icon*: + +{'t1': '🥇', 't2': '🥈', 't3': '🥉', 'non_compliant': '⛔️', 'pricelist': '🤝'} + +Resulting texts and icons will be: + +- Gold: 🥇 +- Silver: 🥈 +- Bronze: 🥉 +- Non Compliant: ⛔️ +- Draw: 🤝 + +Available keys to be used in the dictionary are: + +- t1: Tier 1 +- t2: Tier 2 +- t3: Tier 3 +- non_compliant: Non Compliant +- pricelist: Pricelist + +**Configure: Product Price Compliance Thresholds configuration** +1. Go to Sales > Products > Products > Select one > Sales tab +1. Enable Use Price Compliance Thresholds under Sale Price Compliance Thresholds +1. Fill from 1 to 3 tiers that you want to use in this product. + +Example: +Tier 1: 10%, Tier 2: 20%, Tier 3: 30%. (Use all tiers) + +**Configure: Product Category Price Compliance Thresholds configuration** +1. Go to Sales > Configuration > Products > Product Categories > Select one +1. Enable Use Price Compliance Thresholds under Sale Price Compliance Thresholds +1. Fill from 1 to 3 tiers that you want to use in this category. + +Example: +Tier 1: 15%, Tier 2: 25%, Tier 3: 0%. (Don't use tier 3) + +**Configure: Company Price Compliance Thresholds configuration** +1. Go to Sales > Configuration > Settings +1. Enable Use Price Compliance Thresholds under Pricing section +1. Fill from 1 to 3 tiers that you want to use for the company. + +Example: +Tier 1: 30%, Tier 2: 0%, Tier 3: 0%. (Don't use tier 2 and 3) + + +**Sale: As a Salesman user** +1. Create a new sale and fill contact field +1. Add a new line with a product that has been configured to use Price Compliance +1. Click on the 🟩, 🟨, 🟧, 🟥 or 🟦 icon at the start of the line. +1. You will see a popup with useful information about the Tier ranges. +1. Play with it and then set a Non Compliant price (change price or discount to achieve this). +1. Try to confirm the Sale and see the error. + +**Sale: As a Sales Administrator** +1. Confirm the previous Sale. +1. See the message on the chatter. + + +**Reporting: Sales report** +1. Go to Sales > Reporting > Sales +1. Select bar chart or Pivot view +1. Group by Price Compliance Level + +**Reporting: Salesperson report** +1. Go to Sales > Reporting > Salesperson +1. Select Bar chart + stacked or Pivot view +1. Group by Price Compliance Level + +**Reporting: Product report** +1. Go to Sales > Reporting > Product +1. Select Pie chart + stacked or Pivot view +1. Group by Price Compliance Level + +**Reporting: Customers report** +1. Go to Sales > Reporting > Customers +1. Select Bar chart + stacked or Pivot view +1. Group by Price Compliance Level diff --git a/sale_price_compliance/report/__init__.py b/sale_price_compliance/report/__init__.py new file mode 100644 index 00000000000..cd23411b84d --- /dev/null +++ b/sale_price_compliance/report/__init__.py @@ -0,0 +1 @@ +from . import sale_report diff --git a/sale_price_compliance/report/sale_report.py b/sale_price_compliance/report/sale_report.py new file mode 100644 index 00000000000..8cf9da898e4 --- /dev/null +++ b/sale_price_compliance/report/sale_report.py @@ -0,0 +1,29 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo import api, fields, models + + +class SaleReport(models.Model): + _inherit = "sale.report" + + @api.model + def _get_price_compliance_tiers(self): + return self.env["sale.order.line"]._get_price_compliance_selection_tiers_text() + + price_compliance_tier = fields.Selection( + selection="_get_price_compliance_tiers", + help="Indicates the Tier of Price Compliance based on the unit price and " + "applied discount compared to defined thresholds.", + ) + + def _select_additional_fields(self): + res = super()._select_additional_fields() + res["price_compliance_tier"] = "l.price_compliance_tier" + return res + + def _group_by_sale(self): + res = super()._group_by_sale() + res += """, + l.price_compliance_tier""" + return res diff --git a/sale_price_compliance/report/sale_report_view.xml b/sale_price_compliance/report/sale_report_view.xml new file mode 100644 index 00000000000..c1cec366604 --- /dev/null +++ b/sale_price_compliance/report/sale_report_view.xml @@ -0,0 +1,31 @@ + + + + + sale.report.view.pcl.list + sale.report + + + + + + + + + + sale.report.pcl.search + sale.report + + + + + + + + diff --git a/sale_price_compliance/security/sale_price_compliance.xml b/sale_price_compliance/security/sale_price_compliance.xml new file mode 100644 index 00000000000..38b4133ce77 --- /dev/null +++ b/sale_price_compliance/security/sale_price_compliance.xml @@ -0,0 +1,12 @@ + + + + + Manage Price Compliance + + The user will be able to manage Price Compliance Thresholds. + + diff --git a/sale_price_compliance/static/description/icon.png b/sale_price_compliance/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/sale_price_compliance/static/description/index.html b/sale_price_compliance/static/description/index.html new file mode 100644 index 00000000000..a068e151e1a --- /dev/null +++ b/sale_price_compliance/static/description/index.html @@ -0,0 +1,597 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Sale Price Compliance

+ +

Alpha License: LGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

+

This module extends the sales pricing functionality to display a color +code based on the price at which products are being sold.

+

Managing Price Compliance Thresholds can be done by adding Manage Price +Compliance group to any user. Compliance Tiers can be filtered on sale +reports.

+

You can use up to 3 different price compliance tiers for products, +categories or for all company.

+

You can customize the texts and icons of the tiers via the System +Parameters.

+

Labels of the tier fields will change according to +sale_price_compliance.price_compliance_selection_tiers_text System +Parameter in views.

+

This functionality only applies to Sales.

+

A sale with a line with Non Compliant price (doesn’t fit in any defined +tiers) can’t be confirmed. Only Sale Administrators can validate this +sales and a message will be posted.

+

Price Compliance thresholds are selected in this order: Product > +Product Category > Company

+

Each Tier represents the maximun discount applied to the Price of the +Product to achieve the target tier.

+

Color Compliance Tiers

+
    +
  • Tier 1 🟩: High-yield (Fully compliant)
  • +
  • Tier 2 🟨: Medium-yield (Moderately compliant)
  • +
  • Tier 3 🟧: Low-yield (Low compliant)
  • +
  • Non Compliant 🟥: Non Compliant price (blocked)
  • +
  • Pricelist 🟦: Pricelist has been used and it’s price is between +pricelist and last compliant tier.
  • +
+

Information Display on Price Compliant Tiers Includes a popup to +display detailed information about the price ranges within each price +compliance tiers.

+

Also displays Product Base UoM and Product List Price in Sale Currency +in the Top Right corner of the Popup.

+

Price Compliance Widget

+

Price Compliance Sale Line Form

+

Price Compliance Sale Line List

+

Price Compliance Sale Line Kanban

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Use Cases / Context

+

This module was developed because sometimes commercial users are +incentivized to sell at prices that are more beneficial to the company.

+

It will be useful if you want to visually show your commercial users +whether the price they are selling at is in line with the company’s +pricing policy.

+
+
+

Usage

+

To use this module, you need to:

+

Configure Parameter Text: Change the default price compliance tiers +texts

+
    +
  1. Go to System Parameters.
  2. +
  3. Create a new parameter with key +sale_price_compliance.price_compliance_selection_tiers_text.
  4. +
  5. Write a dictionary with the values for tiers that you want to +change it’s default text.
  6. +
  7. Save the parameter.
  8. +
  9. Create a new parameter with key +sale_price_compliance.price_compliance_selection_tiers_icon.
  10. +
  11. Write a dictionary with the values for tiers that you want to +change it’s default icon.
  12. +
  13. Save the parameter.
  14. +
+

Example:

+

Param sale_price_compliance.price_compliance_selection_tiers_text:

+

{‘t1’: ‘Gold’, ‘t2’: ‘Silver’, ‘t3’: ‘Bronze’, ‘pricelist’: ‘Draw’}

+

Param sale_price_compliance.price_compliance_selection_tiers_icon:

+

{‘t1’: ‘🥇’, ‘t2’: ‘🥈’, ‘t3’: ‘🥉’, ‘non_compliant’: ‘⛔️’, ‘pricelist’: +‘🤝’}

+

Resulting texts and icons will be:

+
    +
  • Gold: 🥇
  • +
  • Silver: 🥈
  • +
  • Bronze: 🥉
  • +
  • Non Compliant: ⛔️
  • +
  • Draw: 🤝
  • +
+

Available keys to be used in the dictionary are:

+
    +
  • t1: Tier 1
  • +
  • t2: Tier 2
  • +
  • t3: Tier 3
  • +
  • non_compliant: Non Compliant
  • +
  • pricelist: Pricelist
  • +
+

Configure: Product Price Compliance Thresholds configuration

+
    +
  1. Go to Sales > Products > Products > Select one > Sales tab
  2. +
  3. Enable Use Price Compliance Thresholds under Sale Price Compliance +Thresholds
  4. +
  5. Fill from 1 to 3 tiers that you want to use in this product.
  6. +
+

Example: Tier 1: 10%, Tier 2: 20%, Tier 3: 30%. (Use all tiers)

+

Configure: Product Category Price Compliance Thresholds +configuration

+
    +
  1. Go to Sales > Configuration > Products > Product Categories > Select +one
  2. +
  3. Enable Use Price Compliance Thresholds under Sale Price Compliance +Thresholds
  4. +
  5. Fill from 1 to 3 tiers that you want to use in this category.
  6. +
+

Example: Tier 1: 15%, Tier 2: 25%, Tier 3: 0%. (Don’t use tier 3)

+

Configure: Company Price Compliance Thresholds configuration

+
    +
  1. Go to Sales > Configuration > Settings
  2. +
  3. Enable Use Price Compliance Thresholds under Pricing section
  4. +
  5. Fill from 1 to 3 tiers that you want to use for the company.
  6. +
+

Example: Tier 1: 30%, Tier 2: 0%, Tier 3: 0%. (Don’t use tier 2 and 3)

+

Sale: As a Salesman user

+
    +
  1. Create a new sale and fill contact field
  2. +
  3. Add a new line with a product that has been configured to use Price +Compliance
  4. +
  5. Click on the 🟩, 🟨, 🟧, 🟥 or 🟦 icon at the start of the line.
  6. +
  7. You will see a popup with useful information about the Tier ranges.
  8. +
  9. Play with it and then set a Non Compliant price (change price or +discount to achieve this).
  10. +
  11. Try to confirm the Sale and see the error.
  12. +
+

Sale: As a Sales Administrator

+
    +
  1. Confirm the previous Sale.
  2. +
  3. See the message on the chatter.
  4. +
+

Reporting: Sales report

+
    +
  1. Go to Sales > Reporting > Sales
  2. +
  3. Select bar chart or Pivot view
  4. +
  5. Group by Price Compliance Level
  6. +
+

Reporting: Salesperson report

+
    +
  1. Go to Sales > Reporting > Salesperson
  2. +
  3. Select Bar chart + stacked or Pivot view
  4. +
  5. Group by Price Compliance Level
  6. +
+

Reporting: Product report

+
    +
  1. Go to Sales > Reporting > Product
  2. +
  3. Select Pie chart + stacked or Pivot view
  4. +
  5. Group by Price Compliance Level
  6. +
+

Reporting: Customers report

+
    +
  1. Go to Sales > Reporting > Customers
  2. +
  3. Select Bar chart + stacked or Pivot view
  4. +
  5. Group by Price Compliance Level
  6. +
+
+
+

Known issues / Roadmap

+
    +
  • Widget is not shown properly if the order is not saved and refreshed.
  • +
+
+
+

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

+
    +
  • Moduon
  • +
+
+
+

Contributors

+
    +
  • Eduardo de Miguel (Moduon)
  • +
+
+
+

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.

+

Current maintainers:

+

Shide rafaelbn

+

This module is part of the OCA/sale-workflow project on GitHub.

+

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

+
+
+
+
+ + diff --git a/sale_price_compliance/static/img/price_compliance_sale_line_form.png b/sale_price_compliance/static/img/price_compliance_sale_line_form.png new file mode 100644 index 0000000000000000000000000000000000000000..0b63706e6fd979a60a0d8b3216a6219bd8e02b1f GIT binary patch literal 9903 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 Az5oCK literal 0 HcmV?d00001 diff --git a/sale_price_compliance/static/img/price_compliance_sale_line_kanban.png b/sale_price_compliance/static/img/price_compliance_sale_line_kanban.png new file mode 100644 index 0000000000000000000000000000000000000000..85872bc39972d044bf1d8c4b07d308cb63668f79 GIT binary patch literal 14509 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 +JlWYDOec8df2rIFi15Ba->lkm^(-FOsp48 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 S;DD`!FybOIU&=n|`u`6H?%Q1e literal 0 HcmV?d00001 diff --git a/sale_price_compliance/static/img/price_compliance_sale_line_list.png b/sale_price_compliance/static/img/price_compliance_sale_line_list.png new file mode 100644 index 0000000000000000000000000000000000000000..8e77498249a0b0e45cf65cd158fc37a5d5bc6212 GIT binary patch literal 19705 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

?UfxVku+YvQPq6sYOB@+txV&VmE zvJd+$R0(zYROid$Kz#B=ASwQMzESq*X3SErG!S*zCss&o9CUZR66%7 z7SJ}6<3L3i5dU0$ZQ1DwSbVg)2X;P#584+%<~; zORovZw?zH=;xSfG;fdD?MxfI8w3yKP`((q7t^i@{2k3g*4< z01+u^?gwzuUGp}JjSv18bX&B(8;w7z(Id1jKOcXra29M#Pi}fkwEo27&)ji4TBwH% z(b4T+R~XDdT{m-l8e;{2IbC$?s+Fw_kHRdOKdSSKHvEnL0N8P!QEE8VxBGhP^_?uX z-F*P{E9(a;+|%e||1Dlo$l}jD9NBn*tCuBJeCegSeYh{~i$5SkrHJXvfEw)=i`IoW z(Pkz-IZ57(0-skbedBBbl&rxHs*$;7&(?9a3y?PWyDT97{LPyZp2g9@uf>Ap#2Qd%=Ew zd6DY~9jVR)X{joU1%Y9`=6)bh{OnCkR2#w@|7q(2-8O;hJp%xT1p(U?uU;VYSeMdM zYc|6_N29g1=}PR*p4elmu4OG7RK=88;g*^1CFSIXPZCG#j`)ObSSUd1IDXfaQMak9 zKpsI#86T>YSd1RevTEFv5TU4^Shf9|x$8FBkBN+yHYl)~DyJy}?2*@p{JlsOG6q5gqYeddBIq;rzesmgSufTlLQ6QDHm-3gBZ+#yDZ+AjJ<?*hiYz(hB8y@l5Kn-jdYV%q3wn=>vgQ@k1Ljc!ad3F1Q?7 zyg+#PoWaC2;=N%J45}d7HRJc;!&K$c?;vJQ^t`7?zbv-CB6D2F6KpRq9_mg9v6{~k zZH$v8oBbgZ*to2_PEvcK{YdNEOuAIGJV6%d!HVTFlFy+-kj1@;i~bE@K%sgQ9vr`X zmEj==$Q1`FYhCCSFg_16BM(;K8I>RYoyLdjQ14n=^QUxG1qJM76tMCWB~^*K)&YOX2U=l3QUoE zCRB!vmBF5#w?ezpZ%zr$Ks!b{S_pjBv$yUsms=5Yocj!cz#z>Gv(XYPKRPj$4b<)3 zIzm+3z}8kG;>@yo#U(rfKF1&6nK%K>MlQW+u(*r$13UAJjj}zGhej5|dwNiF;r$cx zDP{4d^1Y-!H#!}o3I$c;28)YiQ^sT!lypq(yK(J!`~YGzwbt2GiCUiUNH(AQb-0#o z{67y8!AnOu3XAcdSPhGL(N>SPRgML^4j0n6?h{%|k5nNno z1ZV{9Qg{;)a)U3`;_kfu;l0_aH$AFh4!uE^#$b|3TqdKAUcm!K;h#TbN!)h042%1$ z*I@{{blkjSr9S$RnTY5~~o<1Lju_e?Y}e%8v(XuWdZy?XPNiRTS(jRO@Id3YJ`+Yd%Z zEFc$`BjE&8Hs*OFl=XspSuk^w*geQW#wrLy_67>2u z^fz5>-)!YfY=SI)G?_Sxp^{k9bsNev_pY%RlkoMnSbqCn-|sYeQ}LxyG#o8RV*pPO zs*zt-_KF3UhleM58d!Ky#>p~8UrW_z>ZICnh{aKmfyhu~S0vxRKUw>*jerlrQe96j zkcj;m70+~Q==Rt@>L{v9YnwGz2&Zv;mcd znH*{$oO1HdNGD-G9)Mz?=n-%^{)lqOG;^)kQzF`Sj7*;1Z4;}B_G8UVe4I5MwKxTK z6a@HB5(D|j7z1ZpeO?q+(o75e1T2bY8;gRDV;t(^ zRIEZ|MFq=RAI{vn39}Nc(xvc#rbS0q-jyID|wz^hWXr&sA>QH&&sAd?Vmy ztI}brZf?L8_z8!YSiU|vHxNf{Dlf~4{cJ@~+}>k@=uqm-^{rxq;hgip`nn(twHR++ zm4rpQH#`c=Mmqg%<8JVPZmTDv5UM!@P*D4uc^k;5PaX07D~)&Wp3^-ao*sFk zgI1?*R6xi?|1N=PEOucDA~j>v)1*9Ki2nP*H^4X`lFSI&|^e^z_)jpgt_M=vL@EX z-$h72k`0MejARV7T_@aZw9Cn>Cw6w8qgN8~c)kI{;XqA!Y!U?qE1G%Y=Z6vLA~exC zza1_guNw7mpUAMVk)LLFvi|o!rvK=)3i|ly{vjnDk3T!-ffZUxDJQ3-fH|QVY7Pba zrpU?3pOfRjb9w0lFPX@Db$KD})^)O|zFZ}9sMAkN2I~XD_N&`ZK>#+fI7~ty&d!c# zSXk|-71id2CCc@6bP#YbSf11YxnT35N9bx8kHA^N_IE@~8GSp*UBFFWc+|)?W>8Ev zjEOWI+_{}(>amt<|sC5&hxfYNlEM{##{`x+0|FayRB4hR@1&eR!3HYURhzlj5c|HRd~fJOJLAu6b4 z%+MvRq|Dg82nl)cF8KVni5Q?W34n7?xB3D>l`uE=ZuORB2i3fzTTY$lvPNHFCGl}L zAgaFNy;e%finT+5$>*#QfZzxOTvML$nI-8l{1lkPTkGPkM;Cd4UOrtPYJ22;FnZum z{v5OWD_K1x*Yj0q^~!Odc;4N{aLSj<1Y1!I7&o7Im2oY*eS8mz8-E8X4uC`|^JbP* z2JVF?x6tLuYycv7jyb+vUbftY6p zb&Sg3+s;%?m5`hBRolCuB#yRldL&s53;NdBsNyh{4J}KyXaN%#@dmre{w8QG?1^i$ z!)Nsr`DoEA9Hc&V=Q-h?H_ZYadH^Si8`z;Q(9y}j)`tL~b;#hcO2l*=j<<5Rz(tv@ z)!eAKm34j^Wmlw)X>d%fTaEs|Sn^=GFSY21fZpVvJZA?zDHC#yp6FrUxLs{Ghqml_e zOTvwS-!~Fi35tDl2+!eftqp0;=~=LWQ~j*xv0@pB%oRH#3;=!GFfh0tTy!oo3LY;% zM_c$q9oj1^--m>Ts>u*rC2s9y=`pCg9td>aQ&@r22nrCkw-&psk~aGSC8eZZX8BmT zdA`N#$=Sp&)M#as&%AS7I~N5>*#e9Hoy73GAvf$wCV!(oLy$s8PEVWf^*_U|Z~)00 zkmE1^mD4BHGRe+%!sGHB{7GRi4^zRKD3K?>E+I$REk4+IpFeaH%nui(I8es2 zpD@(@fdca4{CFy6dI<0I?-;8ola8dL5=+>YC$+80iN|Ubu%$fp!(VvBIp#<$So$74 zd@2guIXlJtLBDvIFt14+U#wW^ZPDe(zyOkQe~OfIi^mW5id>|0x3uPP0bkMtcMha> znBRAzw^;W*zqs_;5C%V7oZsLmjWg0Dt^wC(@B~K=gsA3S@dtx!yey_lVF(v~<;Jp7 zfo%M9P(py|er#e&aU)%M4)dqM<)k7AoOSrG#=74wCgR45y*non+3ozRkwXmvAX@-i zA`!Ai0RNA)ppJtMdqjd$>r+vy#px2Hnkp(UJh$U%zUk~|@g85~x?K$6s~=sjG>zdz z*~Z~9yO0f;F_onqEgi=%yVCwSq$GI=b^{LcRSaJ<)qAxcBr`dfK~aqYbwBPsbciBDoKbh7d2m18hH#_<{99ga_!`hzcBN~*TT z`})AaQiAkD5M2E|JNx}N>a9nVdXdZ0fHATr^}I_h9xJQz)}Xh@3UntTIhkgzA<)~0 z?Q7DUa^=qt+rP7o2qgK3|6p(L9(obSY0b3AB+!5R1s9i4^nEG1v*CsT3VgJ@Z|=cZ+NLOFS@j*IwR3Zq}@%v*v+n^dDsOdJN?zN0m}Mx}gG zSt$v_PI3GTU9rSIoSl^H{q^sQrwy$GzvyDoetghs{9z)lbZjo@qjzOX*qfQjm{WAY z<1;uoo2H(1?AZBM%Oct_0Mo2xw@us>XvbrQ@(7>es2w`f9MW=j7eBE zL@Re}?($DB+;?0a5qZp|mg`0D!Ehu^ES7|`T6M{a!bvyDbi=~q7rr!5ml-Z4wc*o$ zM!3su?;YkE4yb9j9NOA>4%uvMniIUE2}Qfi=jY*FoJ=evKJf2kPaO4M4Gk_eq#~(O zL?$H2TwIdRb`>etrZu{UV2V%l7B@>wT&M-jFPtg;h0nipUvd>(ae2?&tgpZI3TZw4 zd_6CrWM^g|pey(5_0k7Hh-GT(?%fD`X%y1>r$g1Xsas#ki4;!4qf+kElIVBao(IZi^1Jx|r2W zLO(d@gp6Y`E#3&MJ1lC?*9rl1tVTM^d#@d#|;z_}JN! z|Li=5u8+gX(vrP%W@m$SH zWo*nR*}0skx`>ahEpLn+MTCcYr~0fbHs=>I#>tV{LwtGVtN)UamrszRgH9VIUiWT$ zha8`q55SzEgPAbM1zU4-5^`^zvi?2g80)f55#{N@2jQW(SX(iB+fI7uUK64}+fI5# zC9eks@@B{ws++!N$-$Hk>nN zl~pWDNo8SBAm?6OK(MnD^LFp!XjugSW`;%pd{%gdvo%+UjS9olrVlqXZ3J&jWOwm5 z{2^c%Nzqf8;^o1b%o9AnFIjtxLnWom-?JYVD=YpT8O}sE7oOky6;+Lm9iH2*yzpf* ztI{%w_Vp%yS|l#kRxH@NpM<;u{Os+BjeHn@g+ULJ{KR8eo0{I$aXOrNch!io+#k zlw7Uj(ZN3SHZdl`!=0^#XKtDuDe<~@%RA(@w)VTeAV&uVT3Q0_zm)GEKS5=E0}htG z0l0oc!-prb33GR4T|_7VQ@uU0voI%9^<#(ST>6f*3Kc6RjAPNLKmewBd-3@8U1WDK z{m!}g9RdCO5jQ0Qw?6j)Fv)cgF7K@)`TVy$=rG{6`&V46=g5;W+M3^gcru?Bo@QOv zNrIgnwS%YlN-Y;&dJdzW?SEtAbMq;>TtjT+!#sVzKLAs_JTNpcV0HR&mW&C)(aMsw znJ2L_HDT|ZnHUo_T5Plzi%&>laJJ;bqD6PB3#+-a!4jjRX3F82W_VH%i#zfxD z$YQR)A0r30pRcD)AI1-IzN62-6Ww79R5vwITyurB;ggyFz;FN}Jlyf?YfsVT8le3S z`oj~acq4cee;YRjfJeMM@#|~H>@Rk)_uP3RJ>5wvIL(>MHEf?1%@i*W03IFeP5*zr zPwdHT-V2+ATOVrx0tY(adY|{6x1q@^V`3n?Q*D%^?;BEV?D>mikV`^wfw2wCcG5$l?dA3+*cBItn-YxHt z50{p5v7wR2elj(U89IcvHc0r%gPn@{dg@wQ7~`xv{Rne+)A>2)hmIfjvjCao>e^u~ zPxuev>nCHh7X-k;vNz^NMyTrRJKW#XrjIr}p#AP6(7}O%>dQLqCc6#N4t#)2RV9W7 z1~*O5C%C$x)@aC3wXdlaR96$|;Gq5G0`RuA!P(lX!`gl>tFj8UM#Id(K04b|o{Ax7 z!P9sAd0o*R#(=qz5hjK`*_2zL^Pnxv-IZ4#8VR(o`?lV=L4>F7GQc!%FN}H`kY3UL zW=W>15({G^qC7pcZF(6S)3D@q-V2-5;W+?YYHXz9T0Jv-y>-S*Zxdri4j7;lWN`DC zw70MTpsJyvQ^&g-jjgThC@Ln(%bngP?aMV09`0C~n&>>#RqA#BKbjo@0&k-Pwg3PC M07*qoM6N<$f;57~y#N3J literal 0 HcmV?d00001 diff --git a/sale_price_compliance/static/img/price_compliance_widget.png b/sale_price_compliance/static/img/price_compliance_widget.png new file mode 100644 index 0000000000000000000000000000000000000000..5b0b8e5a72a089abd474297fd4a1a78b791a8153 GIT binary patch literal 31258 zcmb?@1yt2rx9>(UXz2zO>Fx$a0qO2;Y3UB7q>+{sknV1f?(UZEj!pB{KHs}3x!n>PtuJm7x__QK*y zFTj5uFO0r}p9vg9)EpFTOdOo`?TjI&);3ng4EBb0#>Uq6pKTnD;9CU2gD7DS`e0|Q z?_g$Q{YuHq${6BgX8ekU^Odo+@hetlR?b%}Y`mo)9~dd6?M?lH_Z)Z~_zm$&=puUxR2roO&@_)FmzukG-N zh{Ti}P{PqJ6OG;6+#u8w<{Z7fGzm#bI4bvkFNME*quATCMMW#h6-?$%nnMO#f(QJ6 z%B#G1?f0^7e0;p0xE0H1)CU3@ z%&vE(PYNtl(8+E~?IWmkprj`q(_{@9)k?)`acHCrc~G4}XNACN7Q-QfOIOk?Uom9L zFf!54Y6vNA9&mpS8T!kZR4OAa5`>zbg|3#>*rb}7tJoXQ)sR1O-sbG>on^|lyk(~L zJEBC%S-c&zlb$Yb&*`9g%WQgskjU=fy=N!#KrCPzZp8<+5F{cb{JXF8O<^dU-AXIo zz^U3_t)V4Q=#jT|2|mzlv7Cj>>$rKU-k~b-gP4r%NuP9E+Z$?yj>P;}x{-8kNvY5< z_MJCxGH2}nzR+)v~n{3oi-RSD3D&6{{-*_nS=FKV1GYJYA8MI2PML%<8 zTAZF{ld!a(Wm4*|C^lZR&;;G0H(jsfWFZ9lu^GK9;UAB8V0=GlUL51Vcp|e~<1)+3 zX+2-$So*|Q_yr2m%5fQ6=(RaSTgVbBvwND2f{L1}KB#>5_=p`#8`IfFzcw9(iHYTU zyp0HnrHkwAVO~Qo_Lx=hR~C59{_Jw+>(8Hv;NlLV_@a5&S?&;QH}I#d_eVs=XkzI}$cFZB5GB*)KJfMwX=+PG6R0Db4f zMzArmQ45=KSEw^-yCal30xX=1`~BJOt0Em3ds?3f}!fHOZ!&txicz9SYNQ-dR}j)Znmz ztuwXe+*2qo$Cd30(&3V~(yR9zY+fb@ww_xIljg-ZJM(*n#X2ov60u!a%a8#nsqLjL zIusNXwH6Q3<-tT*2?<(FmB(a5sD_3HVMu(**7^>zpP1;%s?s-=LBg>DF;kQn3F*YA zk}CE$cgAlrQxzH$NYb2+q`EJQ?26{=rTO5fclLW4XP3=oK7)gsrdsd0B693(Ki zy|AvkTiKktcR%i-aoT@~4vg?9tz^$Jaa0XBKN7KZ=Yvp6r**FwnqFnt$o9oJ>CX?{ zFEl-VpW`TCHM?UjEYjiswBWrx7$Ni+0L2nIzrdL&Jd{Y&AzRVNYor{sDtS(SWsCz( zuD;RdPqBR)KkqJ7HRfG4PY{FduFjCc)9QV`evN`_Y8YM0d3Tc3n8Jr!t zBO;-7cFhG-qAn0{6p|r{bSe4H%y6sN+@)0D6=ULK2Zz`r(kfH;{~2zu2v?`{^#8V& z!?f-?aB5$)zCIVm2YZ8WAKG0@R2-jO+#>jUUGBWxV$?A{yKLfjIY)?<5XnL^HXAL3 z(-yq4sZL&^@E30FP7$be+#>bf=lhMLB!Hgsci+E&-;>ZcHkLwJyMH|Ig9WHML17`L z3pEw?3!5=P?Y7i9C_c|4QcKs+Wxsv*{n7vHvAz$osbna*=PU(^qWXxBXZ%wOh1)C-(wE zGFRPm(w4yPh3_eJAR(_w1VJMa7)-D+IsKc?6}CIs+T?`VcqnAhadpJwb@PofEnUCs z;l9xUHKNJ0ti1B?;eEGMQd1-i+tyjn{BsLB$tO>Ie{wrAf^=fs>L(AG(BrGQYU^N)qRqc6(NYPlV$ogOt^&h4 zwQb)h3clFUhc^qi?%2oww6W>tw02;UE8_k z?cXi34lczlOi~PS0|SLFmZYk86@o3(ef@9Vq_LW}p^yrlw0GtqfC3tt%IzBygI(l+ z$Ea&M;YYCGew+aId9+@HJoV~^&Gez1zr;12Pa+GmhEVxM|5&u$-@h!NZ*TZIG5qAS zf3sL$*}si3pFB!#Vs0?dlkQpdKxc08AumcMF;Kt7=M^r!j()lPN1~}3t%{jQN-Bz~sN_vS%G5oDC zEQ&k=0-mX9Bw%4+#I8AATwLTSl@Yt65wF}xGmVYeqm|ZQp4QOhP3}4`D@ZHnvE%s$ zm9ZF0#=m|2TE1dwU?N64DSm_Z>&}vIH!+XbGfz)Xl?T@6^KYgc4o4EbbiD>s;R6XK zN~qx8za|!PB*nrqn;t1f_VR|itH@D z@QL2Q@mG(DUU(nYz3MVhgmLfd11M1!<+pc!)KOK7SSIipzClC`s@xt4>F9XdMxI)J zxAil3Z0z$~HeZmqIIM+kO7B|q1&6zKmHV^h$rrH#9OsYl+MQ!E>2SlO?nW<` zH$YEWO=9+fpXZ~jn-6wIow=c;{FHA_&ANQs&I}Lt@OqAjyjuIlzVTX5URif}4CywC z8Yo}_j7P6og&0d4#z-Q7FT|QD!f)5+0PgInPPrxE^LW+rP>liZWxBciy=I2GkRKYhWRI7OEQ^!WB zkf~v;pr~jDl^41s2Hn=O^z;LAc}n>Bjrb+au3Mxn z45?ujS*MmWH_t=ji`MS4_TExbq$|;$c@{NVf`>;%McX>r>1{-jjZ9B(&Ggz!CG)D% zEq3;ELAGa#aoV3Prc#P+iH1-|%!r3D{^gF)P6L6h|sLC*Ul61?YcF%XGy}x&R5*Zo!37`Q=%Gp4Ku&}W2 z;jVFe)q*>ha0OiZdOLm6FwlmHNho7glg}MTU0lK2LT^EbvoxBpTdn<^+v$`avLxu0 zh&z?A0$<9^HI9@|=4_p`CEnn$K?yPLul<~AqD2?GbvK%l$PVh%vtN}V(b-iB*Vmo) z^Lt+@xs9@Na&oRYpm#3&2a*%xpCKBY7QfV&%FWWKDb~Gu^&9~Umo!f~F3X>XBVYL$tA=ish04ZN-Wlln)VhvO z@rBGIr%c^%@z_krGgHkqmLVJk4dxHb6%Va}0iv*)s#?7GY$Oq}>2F}K#(az08X5$r zKYoED?_+$Ov9`%p1~iqQf_wQ5c;BOpFesJGtS>rwT1o^f5sR*6^ro9 zg{CG=xfW`b(St)}L*^|l$6#)e$uX3~bBT|a26>kiY|LZ3%RkC8oGV|2309z&ZRRZn zg~bzEpPC3ES&us!#kq~D2uYc6VxjgUZJPv9)} ztJ+X9GNuXvF!D#Yo8YF?RH;Bgp6bG@PUffWTFTJFV6s3`-uEyLaNP`@#KLu}R$_i= zYlCC2_-G$R5Cb}JhEB+SbaGo8InDLhc!O~IpL7=%7@I*t8>~Qsyl>TiC(1m;Yi(j^ zm=#O8Ct_iKYT&cj*Z1vv5B0#H?tWlZ7R3WX^yS!$Pi(CJ)Yic18}hW~^3=Cq%D6LJ|fS#Cv-o8eD#b8HTAp5)+aB0BB&MNk;lZV7rM1 zP)_v+p*7K%*~M7psXbZ?s8;)wc10Fy$ai5~-L#!(QCKS?<#%g~!rA*8dbU_BZBH3S zlz(v?e?ghRv7Mc-WaCg|)Q8fgZW9L(3ht&!M^~psoa5eDYPX0ePKkMJ z;=WG1s{JS<1sG(-43!(ZgD~N7k(dR9!oHj;~tK#6CfIZPe>RU z6?IZmsI+UCw6St4omDVdIA7~qz;$?hO{HB)S}H9qEk_&MK}BiOA0L?zbD)!e)sP`V ztuqjAK1d|rtVI)7ywE}B{zx17D^b}*&y(ss$KL+3s_Uep<0it70o(3mY*()eTFp8C zA0KXwHElHCZXvgiwx<61_41OGTe##frK)}n06Bd2RZZexyiD1Wh_Z_Q+904wq;__h zbs@efN2oXGbT}K*kP5wQHgWoD)HJ$D!6^9kH96G@D42q2UQ(c;i_6HESTbV0Jq5h% zE9kkPA9Spb;TW2keO1Vd0DMT*S7`Y1cj~g+mY5e`2Iv?fscGH<0s>yCY!vgN$!S8@ zT=}|zq8EEFF|K2 zp6^`;l~Hm-q4)E7=X-GpbJ{fMm^A`*>`ekb(V9v-4j_2tv1W!=u~zEVF%lH0dz0Dv zO>+oO18pyEtR8sXf>YelPHnfUXUFKy&+)H~bvdmUr41BV0l_bbZdnY|eE2)G80aBj zE*Fj-1x}ftpPvPTIXM}pPqCt-j_Yr~&si&1#m%c}j^=uifaj+aC8?;ETdNkS zJf1bn=Y?x_g$>Mi>)I?M*n?uF$t_)DyeI<^HlAhuBwn5nyl4@ zOtbf!4By7OG1N1g-SA_(xg|1YoL4$56)2}b&6TMrDdT?ql?$zCT-U~zh<9lF^9NF; z5e5{Dqse?3k{WaV(@P5eH%N+#SidA9CpWD?1l=o=4C=c&hm zL8&UYPp46lkv(P6TjCOZwbqi{*qI;Zah2?ga(`+iM6+gTC-AS(nMji)6@^?%C-Zpw z+v63pTFM=1HKc0O#jZVkuo0g> z?uog~()Guz8s$0zb#MOuT8(z(nu+osZddkWMxzMWJRm?8 zFabyojbHHcslHk;;2DY=6&4uZxHOJ~Y+3Y&-)2 z$Ki6EH)m!?-Ud4*avVSt@}{3x&i7?zs4MyXe)tc?S)L+4yy6hN1Hy{8vasSj91 znw*=P2krp*5&6(^^ zh~NV{-RnE}Z#zN`Pe7YoY0I*}Cm=9?+P_!RICJD0LsWBU-t4*wP$I;&S(I{VAhWwU z;ioZwa>4i{Z0i8?kRM)@W}E)gF_VBsIyej1hyr7H44q~$s65A8x>UJ)huJ%Ax(#Mj z#j$-qqN3WGo=0#P1N;^4Uq_(L>v}w%v3$($@J$qTiJbpU)EX7j-!;5Hexttj$rER+ zSqi;NLJ?oiF16>}EB~}Eg=;z=+V2f{&bhNs_p+Q^puc5dLF{pni*O?>nkB=_!K6lw zxoh5o;LycUu+WHG=)y_Va|0pmW0Hz=t=l~s0OmnJr_nbu>KJNzZl=^CQ;d#rSY`3$ z_*guSe)(N|_|X|C#eYYbWm1|tddKm$Pa9j63qFjWvgsdYK8G{>qPz z4*tx96oy#ZxV1GIQVm%P5`GOm&5pz@Ixe%N=t7k&O!Aif=sm2-q|)+oy{o^1cMsiZ ze*R(#=^kd3VI*-!#rPH$lAD7!0Tlhy_MnQMpPyeXLv^t*aa;GvyjNB?h#U@&9}2k6 zwfya})SEwZ3NhS-A;*_Q19S|kRw({hAnUN^u=S+&Y`GK09@kOZ*)d|w%?-PaLWIsD z@5b#;{VUI@wGRO6n%)+}@6Q|rrC4?SPHnw+PnV;nHrMp?w{JFF%2)=MZ+c8=lJF zy^GpnXk{!Ol6}E}&g*<6@P?B!oX?reZtL(vQKRq8MMse+PG5|j$u1W;I8#w(6}SeL zmLGQ%hEVQTD?FYm^|(-M6tp&}x47a7F=~DNsvsVOdFe4R>zf_6+q^N_Q+fgQ1U%eA zP`BPrk*&;OeWW=dF%h7JfkpR-T6d<~xBVyNEx)A9@PcP&PbVXXXDhAH>87pQ2L@tj zWBbP|G`qF>IXO8E&Q;-Opj#N#hurUsCKLfw>ud({qr=fhPZH@+4|N&sKDQq~e(WCi zd@fQ?Ts_i%Y&Dd6Tz*4LvOimPGTl}T9FaTnLal}{AenD+qZQFX$=nQ0Ey9G;d#)T4 zw;mtX>b+rxMI%h`v%PJ*PDlO6;&@QjNqu_9xP$8265-ad+B+yE|^22yFvy{g;^vg&J^#Jfr~PN@y~1cg@5|T|8V&D4R&ujIVQ{-c|r2y z7tAf${*Iswo-Hi)HWy}o@V@!?PsN@onE;Zcwp#t!@DCW{bwdJ_h;cvvdpu0OX(gxpk&vMD*Z5%8JK@M%P3iB(-TaP-sAzsW zdFBRG*8eJAz3IUjKp!+8VOeww3p%Z2$&rh8ry1O`so#ouV=n6lA87Dk_y?(={@Mxp zV2DO4*bB5B%*<~B1k1Lg9e&keEm zafFCZ(*j)afWResBkzm#`M>n z4C`~7%fLdQUXd#-E%-sF`}it3g~U!B@4NQsn`@+82xw(M4}yP*LC{HVqnk`(zr^X2 z=KYEyh2$r<(+daIx>YyV@y}IcA95%M@4IY!?>(bz$JPPO0;-u?iRJyki zfOm`nofqZs5s_}nQvV=3Me4x~AJhtlmLqcDPk|5i5}&wl30~~Q3zXN4f~l-oTCk>t zv>^%q5+A$m!MEFWbxqdXoOYu>HrqY^QcY9y$(O4cg5u@#Hl|lE=p>N=TT3@=Pyt92 zB9+V<5A>cBul}yYdM~yMu|&>d$)vcj+0$D=Kz`&iTv$@Cp1vd{6~DI2x-Q%bq&`1S zIKP+u_)!!NW}qy~5?-K1m_GpaEuX2eK|FnZ^K^Az>4}e@-$zl|DSJKwg6Up@4t1lj2sI!~PN8A1JiC)^4{3S8BROXFi z0*50p5IXwv*zivH9iI3Anx87#{dZMMSTP=~Kt`(Q+A05tpI^Y)b{RU@50j2a!B7Ih zNPybxZihS}qG$*3e`?kO9vI-wfh)6uArBDOfNc}_ycB6i-g2MtAvH$}rg~|lQp9Ws zreeSy0Sx zonemcqcDK_fE=#RWX1EO5(4!H*eU?A; zO!y58?PSbOZ8g>ZQZYpk12p#f*mr8e-WmRvXjPw5o)%n*JNnK@^;2N2z{zFDNXEID zEDW&0OPj ziW+t%Guc4fhs#y2phYLWKHe@HP9m-L#sO9T4*B}}saZXNE-nccvQg-?zh6LfR8&UY zD0<8fDa(J$0^Bw7Nvc}4-3AFw7AUthsh|SI4^nG)&pBD3GB&5OskRSLjJT|9&if`_ zjXeg2K}4^mH)qH%+KNASg=s1d`@9OCo6|El7dBF22LdiwNSz8np#6dhhs|V?BYw24 z_gHgaa(;&1F*Ebx)HOk?#ySlHfi{L| zaDf1;*8PzM<_Juu`hx2Kb`lX$kh09$>v^o%_*s!aagx$W6d=YVOh?4@dU!vp@x|-m zE4M(J%i12EPjhqg)JJ07mRHzF{+j_uWG|Bq<1d#*)Le{;D@>0BT|y0mRujb~z@e?3 z9Iak-{?=P`OV4vto!Cl&oU*dp_Dw>mgn z^ZE5sI^Irai--GdGJs|tTq^}ydndY3sK)}9aAVnkxCGCl&yj%3z=_FNVTa4&!r zk(@#dTFUR=*-$OAP{cwoC~XLHw}9#IOLYFZv1Pr!#W?L9@M*QrhgHa4sP`D|fnvRan{!Q60hQyZ+q z69TSrzyb#Oe&Eo>tej3J0nGv?N-s}YZ_HM3$4GeQE9ZUE^5EZM)Oz{~$ZZ8vR_&mw zyB!&amhIAE`-vG1EJ%w>8}}c%i;Ig-E)3k?`IbA{m{=`l|NebS&xr=gv%^5e6Fx7S zOh8XDe#qaI8<2l}|GJ}_>dWCOhnGerCRO(;kiQ`=qo)qNtm!m+<>%)PPo8{W-=gGy zgPHX;3Q$8JPMFYf0iv8~ytf)-dhc=1*6(7Qe4iGtnH|Fx33V-2N~f32OtXDZ=<-z& z7JdVRB!dCV%ZGpNLq0)EUOuu4J`fmMjHh|?g#$c%vg#DWq!~O!hSEB^()pH*Y#Cxc zS%~zmMYf^b{qc47pvEU~@q+#iSk#Hm!7k+dsurH_;n)Z;WKeqKnk{MS9s2NdDyqwW zKW;5v?;X>C07sZc3XA;C-lzb3$+&~%a&YWkHH+hvaA+usZHb~UVE5*fBu^2ZDrP2L zs-=4CF)M2)Js~Iw1PO;y&*`+c6j>unnXd0i+PF*rQ+jA>DAV7w&VICE0hJTGre>`@ z{0Qb6N{7sYVvCGyUlf;9qWhAvOBA3sN@nhuO5o@p(*cQxynC1xye}Xp$oQXQiST27 z2v#(3b9p@wY-ng2(}2bTj8G|QX`jYnd(bDIo%eDN!)!!AJoA-lN3xZpYXqGhaZke} zU~wXgz5}Az|06`(nDei|k=T;pc6wmy-KRPj{C4V#rJ*>=`zK2z@_&K3|4S(RzlQ7o z{i#6p)Bfj;PpUocpIqgNdH*XgHBb0B2<5-L{1^0Z3|n*;;PF18C2zTTVyv(9ijyb! zYCXp$hD$&@tjHBHG$xz3I2QJeZXVA0gMXyIPV*eA`tog8R?IzJLb7#X<{2Bg{n(_L zpAd4N>e9Lq4n@%}bLC{?lj1Dcv&`SWe$CCtXQ20sKKW2rSp$htgic{8ov<3WQ(QvY zqv(z%|M!#ppRk9z!4e&XxeRjOOD1L#W>47}L*2is21$EV6#s2002gVN=`x1DHY#8! zM?SGx6zMA%?_NJs_7@f(Q6vMOfOq-wk3JU6m>#Mrkd&9rB;dW_+pXFb{u?}@=9D(#y^YZ5dB?FAk<8tn121~A<0D_F(ITMvpf2>#mXT$#Y#dt! z;}?ug0V7q=5z9R<^|G%z_hP5Be@2r9oz*S0^Cd(gn%jBYS9Tln%{gh634zm@;ng3( z?Q&&Qec}GdFAUy?*>ci(b3PWG~$Fd{-zc+sgg;`d8Wh+-vk~99o zBUZ}f)@haIwU-MJ&CsJw?>_EC$;?hI*4_H7#QvoZpDMnKtnAu(AhMfcIicZ5?m;fU%a7J~3^2oiS^9WhDrV zNmk2!Y>#_*dnxbzbsm_Uny!1^Wxqw(Ongjpi%x1h)V5*M#8l5~3TdI&9qx0SzpCsG zyeo4hAepaP?M_oI9X_Lm$a>rp@aXdvC33C*30Ic7HhBv&C>`~Ke9LY^Oa9L;b|aIc z+J+XNgVhS?MtHKTnoaitAk~5?n;eUyboP^NnUV+cYeU^GMMY3zrTObEWSp{_yh+o8 z#GElPG0z6l$XHm0uN)s7=Bmt!$L}$pykezIYzNiI;FB(G9Fs3F{0y53eSlQ*r*#eV zLqns_-!pv$Ky=Pnvf&GK=>r77m4u5mrh#OBTzGi+l*t_s%|gNF{2E#MT{WA7mD8X- z_!@|tjPtxDw-LE&=_ug2j4~9T0Mw@E$Lx7g3s5ohW&s>;0dx-t;RgHPze?c?;Gb8v zo7>qTT`VrbKAmM@QTaxg?5DpcSVD(j)TtF9trH3MTUF8}~l#2zHOAh$Z<+s(;@B6 z!0UdHZOc&4tYi_a^uBpo}#<9~j1$a~4C`{N(>>;W?X zK%sW>%Tw0kSq+X#XqgGBs+UfEKyGdh@Rg%Vc=2%A*c5T^xKkc{yY9GrcdJvL;~%>^ zOXJDAN8m1;y8Q`X^d2je*9P&OKCbai%(O_KpLsMe`@-V@_sqmIy5vq6JG&=02cL+* z*urd1O%vp0{41j2#d~R77QOu{GU2(bmpA(1((FJevN7#yv|3qllS#evX5;Lb-y@35 zTc@>Aec6O=E4>E2fXj$1Vi0DDKa04E1Gy0clbf5PMAbZ{RL3MIm{-u=Er^d#T~<+H z+#01^J6+s*(16au%4&3b(|LD1&RTu2AX{RcqSKJv#_HPkrJ0!HXV;N4JgbIFbho#E zbmoWfokgGr*px(EJN%)Z7}j(E?zu@vyr-R%F8&rh6)1vlw2QJ;Ylae83k&Hh$FNwN4&vi%#-QTB=fK45%Gw+0f zl{M4PYAu}M6v5+po^+g+_vGDH>YC~b6^RzjyV%&65^64Qfi?m}k>-=Z*(wEBAa(yz z9Da{oR%S4ErN2}{23b~hR$)6Z_GA5zO>*v(A(|6V9(VI;e(Qv*)4L4dJ6bvA)6XW z!j`C@_6lK>QLQU>&sptmO$h}xP3UUFad5Nj@F-hrcI>g~SAI?p_j|US&@AKeXFS>R z)Yy&7+05m}`P>&Jhm zH<&D5^$Lw-84>}&(_7s1ysWw$;m<^eACMfzCU-5lYEF$MH(b_+dO=5>t=@y!XM0O~ zLTi#fvezzCp&Z7u1ktjv1j*h+7=f<>97Zf6qQIaaThT>n>F;4--Ua1LS)hYq00k*n zq=MSO5cSp$tHmPEHdOp<+OOg1bhR~_&Ho{bB z|Li3(W>*j5co~C1`wh-nWmjwn4ui0iRH4dE?lwLo7_Z+|VS0Je`>?@VIy9`Tta|kU z-lk~dwLZq%w~ber!A?h9Yh#l{{9dcwRYcC)Bh{T28+aL|N_k48ScZm-I(5bBarp|i z_i=dE9Qj6)-rL(|ZRBsR8>hP=06PPj`Q#}bVo(rJ{+b;vT1U&cksr9!skpWZVb^vx z>LM)3bli9tG`yD@`F-*%(AWD(mX|0ImHnzRrGP)_jl_NO8TAEX#E49Z9CfM<(-!}A zR=<55xWM8wTQZ#n&FOz~G0cA~BAnE&v%9aJDhC(FU$qSGsj0_g`xlR`zmP_XB>9{U zPu79U4w8x>EHf1OBbwX!q_e>aP(rzZVrt~L!VD0xjA7il(*PSz59Aq`(U#_|-R~PH z>q1D<{^+F2&`MzjxV#AlS zPNv#b5wB$7FTe~aBomqyh4~gDgUc&GtgNE6xL-70u(wpNH^%~|Bjn4?0)_Qbvsl4a zG?C3>UH86gi}@+AF4BO*0ZZ9~JP`=Imzt~{ft*HAMm496GMRR2g=>ha!bJ58!$9;N zTbB&`+bIBQjTbjlKx)B$XIGlja#1=Wf_MA(owGO-E2{ztDS>q7sqvC}dNj1kW392b zmzH=SpS90Wyx8m+T2c=~$?a?$AB>$WVvbFkPg!222%(cu@$&}<P9LkC=z#ube72k1;dDX=~svxR@&TVyc4-yWxeQ4o^-6fFlWa+tGmfFmK7vfyf{ zj}3D_uyY^R_jSSVGhY3_Go_E$AT3Krx7Su22;(6Ur{G6{^O62jI*vX&V+jmW0__<` zEST3J4~pCc^lIlHuVwku|94(@I?@z7QF*()7?(&0m!HekmoFnNGFY99^GS*mqfyS_#WboEO&OzS8#X@SZ-ZdjRKH;5M9k-yGOEF5O5u zSxAvlFIFPnr(~Z=qZ4ytfHa#_$`usFvaHPZuHFs{cAP<=0cMf`T?wf7H2e8M-am44 zMq&On2%4EJU&dlrRgD(%fiPsjw9WHhBI7VGyg+3Ma4Z;R)J1)`OPM108&o3HuVTGA zC(Q&@81PE^JwCDkr9S@)2obma11spI@9zcE0VK$#t8x{x%JlmgVS@lb z%h-^JUvCe$y830+uhkz6n;f9yvfJrHHCS8C)5*fTcX!Too0NE3?R~B6!Z7Kx7a6DV zxf3sYWi7`R`tYHdgezvS08at>b^+Kee1NT^6~sM3h`zRn+1D$+_Hd14NVi|4Zl3Lc)PP4QD8uE(O^ zM+~C0#DS|$v#0gS&Kn>Wsqsj1XUKqyyIE;D_p198q=kINyh4J4Scp^^^;*%RT~_yo z6T|obQ#f@Wf^ga_x33cj$=Cl@44>~k&_GZ-?x&SWt?N1OHJqvOq_CQ2H{4vH*cWo4 zpFR~0O$$)aP1^`DsaUDGp24H*D=kwzzCa&X9>V$cxK79MpRloIUC7(FZ^r;zzU7ZF z-wvcz|laJb>=jE$u^+fkEoY-rY{6 z#W?>w@1u_%2$zFH*8MK;x1|~0;gb7{cUm4#D!lIee@1s3=;{5cxJhpK5)0`0OHAA! zru99!pUtNW`(lE~sdrYtgFLRsXwCE2=_`QU{&jrB0HR{+ySxsNGY8r?aQB}Oz)b@i zzRQE-o#};_HW0Z(NmwEgGoPDEL3-ct@c7>xTHo<{^MX|R_1qG=5CbekxjU8jbM0n) zixJ?UVd!#rTeF&cGBL@OV=KzDi@ocq+o)yxXL?4K9RWCJd0J&!)sC*(ArLN`QXK|}=DMeMBWf?B7L8Vurv60y8g&JW(>YSOWxLVma6 z_=uZPMdWQd{IxxljOPW&v~b%WvI-6822@qy3=&Re={NC$G&pRQjolT@BS-^&2n_U6 zxAsf^v#m~$8aG=GozTF=$Nnaf)z;aY{_axs4hl5Rmp;EDONpmVo0jRYkp{m32tMAL zNs%fh!|XEgR)RNPZz{%?8=EQ4is*l^GW-9)%8196>2(7@ZrYsT>;$=EVgkW}u0OQi z)C_)i$Jj$cT->;?&>v*UUPfmRukBv&0YhgEBomnBnj7Y|l&6vEih-P-4yN|5m!|S6 z;%mQL^EPlY3~~%k?$zX}FUIq^}v} z&pg9HMi98urivMjb2*FF@|GuTPv;bL@xF@{nwhq6_t2u9?#Zppk%Zjwa?5?L8FZ=7w1 zbX36@lkM4%z?_2@i`R06h1gsr7>7&k#R@pv$n9nKY9WGvIwnQ>h$9p3zs!6+{B#ny zmB6`Vc3!aq@S?bC2wV;j!pRcfvL6^AB@SplG8!45GzO8yZqrs=$=&tF!P+oTxL8=( z^;WlE+te5euSb7X=v_Z)PvEq84ao5J^~v*@88sFlF>O9F{{(u?NU4_Wdfa$g4=~2bUo?mX@PB4=KvdInn;}mb-8vIf^y~uq} zjg4eJUAYRVH>>f~6U^u+W|k@IBHVKxcdT&}rnju8G{{>Xffk*1$xUBkMf#@<3@khF zJS&_HbHM14!?`m|#RD#}zOiwyu9ys~7?B+ne1DGVbH7z}`Ut`{_lNKThqO~Y+UJtp29mZW~H z1POmH&6-$S^5zl3066@GBdll8{)CUQ2?-rk_hNuXy!dAVENtAtJuxxyCDHA$N6zBe z2qF-xpndO5R4ZQqvxPwWYB<(SP@S5d9~MK<*g-x`ZVCd5E*dFMClF=Ky29ow&A&zH zz5<%qCcYArZi9%P+zUF~!3+@OhBUc5t?XYH5c7MKkNEC-TW3Up6fP*d3*LW=s`%g1 ze*@=iaO*$gdc!aZgnu?j-%M~u0k}qt4=vIpP^b_izOSF@kiqL2o4p6)BanY_JQL*H zlKy{@JRV)HEs_QS^#-%&dvSyPxN^FeXJZ`fR@*Wl8wDKt%RjwIl*2o84^(*Muqn6y$Z7@v42X(PK@8AK$CWwfrrae94$qg>b(b*uB z9XyKz6J=l%$}rEJ&f6L&vsczr9&R2JUjduO?Rh}_p$ih|ca<&_08&jL41vs&-O4E{ zAVwf8DV3rzxm=&jNLJZ9{T370Nw|0Ai3H1GS-(RJQ_Yxk>RMq~hBvpyL>l}0|Lu81E_Xo-021zLrY(o zA6yzb;&CSe(q|Z9V>j9D!z!2{%vCZ2BW7iQ95N&}ZX+&KT19L&nk*z){Rd8I;_odx zNmP+n=BAgzp+v8{xftuVF)6E8C*LU&onhBnX?%FZj7rS;LsMr~R~9x!b6ZaVlBTd6 zn>Q)1jEf64Xzc0I{ap?_+v%}horw&v1m|e`&3KWd`{1eS#v_R7f|-N<%}L~n$(6Cm zBo7WJ^YDwIy{9&>b-Klx0zfV%(aIHwbW)^3QfICpu{z;Ykj-(J0Jc3+J{p{)wxyxC zooF$|iRvD{JZQOcl6}f2wu7V}iA)OLWk3A_zQ;f+Z#ZqN?m#Una>VbWNLw`xHMufj z0>CgYW!%4I533J6#Orj!!_uI-N7f*BmO*@WuvD(aMzyxkSrD4Y?Ca}YILIk(c9bTT zz~hYAeQ7i1VE_DCVCT1Rsej7?u&#NB)I$FdAtSSCa9aI|*Y&s@G7$&_;(cB>9#3%+ z7&R@_s9_kkI05NakOEq{%d&uhA24!m61F>*GcA@lUC#?8{s_`VbKPe)pCTexw|`?Q}7-1z?O)xXcW{cp3$j>q|i zqhsoPMxXYUMN79=O{$o$B3}ldqoqUNyi$Y6BjDgXm6sFzh>a|bfP;bXe&@;9`+C16 zcT~j4CHMJ#FK_xnM8ubR?X;;-*VD+9pYBJq1m&hjDST|g_}|~c;&^TE-u~ksK6@h$ zOY$+l6o>u61HZ4I6kvbJ0Oey8Y`yef1;YRM4YM@yzxPoWnRE-wERf?& z;?l~^T>JhE`|1mJ<27l7ZPc!Ux7?Ob7IMP;S5efR6=T}`-mk)2j0sJp>B+t@21*)k z8@QjtbnmX{_dFaA!v*>k^MLk!(-nQ!c0XGL55c)aYkk>O(&9 z%Pda#-~p-3Yv)REr);lQ*gsD`D{5eC#JNJv}|^f!C5V-rM+QW&{YTC2b(GE1#pn4B@-nH*H;wh4@ulwnfI@ z;L-`X?n0GH%QlNvd{bTa<4d%*x}QpNdtSZ)d}pEvt8K5O(Wf8;3$pxpPN)AoB14Im zfOFk7k)t?=N%NP=+1Low#r;Yzk=7W%Dr&K$R;j^I5wA!Az1x19%MvNu z59Ijb6EBTI23TA_I1jvwf?Z9CRTKREz zo`;}5dzz;JQJ6{rlMr+(Ya&-?@-rd_qu`I$cCqOA_=weFM}L2R5AGEUPOJH_h9vvE z4KAzsL2xJxJ4(M6>heT{A84FThLZJH9>o=Eb;nv@L_AVj)9taqtJT|)tO+H_Q0SH@ z?72F2sPQjoOENTg-_oKTYH9CNy{#oIO5$oZb&se>>znsJjUo(F=^qYJ9drJhRJnac z%+$ylRYg<{7nzdsAtZbl@cbbI<0+e^_uz*gDJk~Lr|40MaWbF#DW!R5@PkuIwBJ z=ShgXqab6{YJBp}M1?at$7^_NP0SFe{iO+L#JocV0pbO1k(kwl|Hf1}Yr zpkeIe(szTyZ&@hY^NV^%S%uvwe-_%3b-fecU;OE#fGGrL;5EQ3aJ|$)u~tJ6IFbR) z3y-Pu{JUOmm`Z&KWPeUY47*Q0-k;9XPFc+jCH!cAk+1Tz6khDwt}9|@c6KaZ{X-&W zes}od?amyFlJ4ek%I)XTN;qI}&|f)jc=j(xG^H@K2cV3!OnqiA)TzO7=hNJsKw+$b z(m(JO78NFNTN{-b%a4|UKNjQBxbSr~4GP9sTJ-t`>KqPc0vPx%neP@OPA)F>PW_;^ z&h|8;GUn#IU>1#DQ7UIx=Pf7W*ViQx`j?eKSGTV5_1a%9@PzMGwCojHPX(-tosour$C zne*@k4^N#|9d7G)efaBTQ??oBV`@@s#>V>_J;I41$6-6dqBz9pmUQ>6Y9(}(S{siI z+X0B1^WK+XM=K4Er?2W!$N?-8_Yi`rHm9Di2t>?h9_CPqWPQ3JX?)NWckJAiZsgtO zxqm4iAD#}TG=45W&PWxQpzcf*Q>;rgmu6B0z zw)T!DH+W=g`|EmFTyzg-NE7CQNv9-$e^P)e`1V z_@c$jSH?<{b*&*yo!^;Iv--$*w?>G?>Qszkw+WN9^_#A$pb~vAO&*H(7Wou?if>iy zq(BXy_X*O6r7MUJh!U}FRFnZ?^Q}Nxe>7^+$aL$=h6X}*HZ4dfUvD;~naS1Q0tK;k zVIed&#Pn?AoJ<{*PNws{lP`P&pAOG!O&@E&a|JecxRd|4%Dy@(%C}n^EEGi$1e6p> zNdYMlP^4?@;ednxqo$q{q!kU@qe(u=& zy7u1JzP0CLsF8;|{z=7Fz0Y&*okgiz^jjifzkj#>7=2)~w$^c3A(zF1J6^+VIw1fDQ2M6Esdz0{?16GUx=F|F zCQuFp4sy zRfC_5{hLBoU+L~2IgdC2pKx-ig^ZgzD;v9H^IRIU$@5Vp4cuUP32o0TEcBg;EQXik zj8m*TMp(&t-?ZYR7n-S3VDu|2>J1150WjxV-sX|3tgf2I>o3j5>toh4+`Jh!CvpYln~0j0a1^;gi6MO(2X@$nV_Ps=OebcsGj05tZVmHk_~JQ(bPl=Qg`j zeOR)I5SPb)V0$+9hChCfBViJ;FwN!vMOQZ!I+|*HSN)xNvL~OxRm{=OjCbcveV!T; zAIt_SLlyyp1&QPeJCE`-(t|b+vFB>By0ILqgh2S#JPPAaw)Vk*yao!>RBInYqp7)< z7pLnYZQ4HL#16lJLtt}jKYT)-8YdJ5gLNGi1~0*icfT0xd=e3%iH;_y9?NI!Xg@pE z9Ne*SP4Da)tQ)g|T0-1+^XPyFxaE(N>9g2|E0mfAlza#qU!16C05d&#c_KeQe>w;| zNdlY4SjQV!q!?yGq>qA`+iS3uE=Eyr*S@cyS>xe@m(|-}uCVqw3K^qE+c!{fDGC1j z87Y+_o6M$^B0I$gmHi=Vd){d;5)#*4DQEc>c!d_+jp>`IsxaSXst0v{G0-Lh@!LCk z?!V%t#UM7##eHIM;W8?8M z^m4(RTT<8F?XgnZqPpwlx!FG*{pe6~2yncbX3k4JbW@rVy#%4P)+0U{}N z#?L3>SQn90v|KUZFST-qL%kM-5j_LC%cmq6#%V6tQ#(5UyuD-&+O>7=shv~HaP6BUrY zq)5P|)w9enYCO2H?@}-5Wf8r8OVgKpTdHL|S-tL#kd~0qzt}o=V{=I7xBh+)1DxeI z@6?%4Bbi}8-Q|fC-nAgj)~IQ{i^zTUTuf8*&aB%lJ0exL(&736R3-`4MFwkUqk)9q zArOc-_g0F$TRLaYc>%ZJ#@c-8xzO3Pc5BAvp>cx&ev^WOBO!K{*MRix^FgF*&{&;a^}!&4M?T~-Rt>{1YW{k z8yiZSe;XyblY1smrR?c;WLf7|C97JDZ@{u!y~_DnG??l5Ad;65&$LXP;HEmT$8(2< zf+F@$&W)YXVEoK9FTvzZmS5K|q|yD|5GOy6x|+2Zf$XbVXmqLGsYf*PFEtZ4;NZHo zDuco90*WHxs7{Zg?eSQLlpZqun1^!R;fq<$rbo0H1)DP&=EcNMYq?I?H%1=4ahQ=y z>)*SmSriwOY8@UA7hjxh>e%m)PUW@UAbIS0nE(o70N9YQI;KbY|4+j?YY2c_4E}WO z?zPbF62>u+B>imeDnvqBF!3MeZSrMpwhc71XL@9=_UFA9l^WvF2Ca(=ak)kIevDX?O z-!>tk@=rU+yCl0|DNytx36}-2r+@d z{$^4M$dBJ)`sL!3T2{hlrHGwmzJs!n`ORFh%Rzk+6k4-o>;z-tS}BfFTI!mbo_^Kn>1$%Fq@jmE-i@m*B?+jUlwjWf9Up%XB#oLspUrSC zx#W*2im9@6KzUEcaC!NX`URef3Z|Z3J0H$>8lDb1)+@q<54MTGxQI(jVkFBMSyt>~ zvfF?m?e8j#&xUoPdnmp+(2JahqM0ibI1~1L)=6pDBzv#R2+~_R4cJgT)fVJfwKZPW z7h~fNum_*aSjedq2*15-6Be4P6t5fJEf-wKj*eGc+*;j>6H$=s(IM5un#{0gtFrYs)ANQqT zIc8G!I2hW6f6?pFXu{yzAG_T~nJ#i-1Q2`##|5z$V!iwr7cCu3I>M(_>lOkA2YlPt z<<#B4d=wkKN?!-{MA29btBXrUP_yfR!PQuPQ!*~gmbukU;Z1^&7e`m6`X;9}RY7XC zW>^BAxA@@r0iyRJuHWExg=_-*{Crs%8B&gqhx{e3!$Y-=FcVQ>YL9MColQZ3*^M(;s6%7pWpQl1D)`Az9WG&R`)%C zkOAUv9^~#OZG?q*vU&Dn&N#cjjq`;53A%P=yBRcw99iDzoE#5CZN9i2x1&FIiBj*& zrEFc8-%&Ri2Qds$>eki5k?BK(Wj*wdpQMUhY)-Zu-_QQtCoMfKkGv4GNn&4=JlWVw ze*}guw&oiF9^Tf@^*vcWHZq%ql0Aqe>-HnaSH<#~>lT|=I~oW1*F4VZ)qF)ohx7ON z17 zZghcqUn$@Fja!3idy^&r_knDNj{@&blGqdM(!w65?(^fUmq1JL9ZXqJzNDn&s{c#a zkI)dq>lIS!&H`U1V>ZH!=yqpQz6;%6^!;4|1byyD`hbaom0(xgIN7gu0iT#fCGTAe~-$=F2g5kuWVhH|HO2mKwvt@RYAxdJ0@fVvdvlefKPFE=36VFpU zw>m!1VBuOGq6R9sKpU4*A4N0HUgS4`{x-6pT}>| zbqyT{_qo`0H3#~1koq|!{6;pn%9=ra_7W>^0Rom0Z5qQ2 z^tKwSXV~%W&_X@l%>pnhCKtEp1@czZj~7Pt}5^Vby_%EZ(eM?YR&Y=-0mb&%RLPr>qL5 z*!}4~#zfu_rpZ&brtK+V1oL!OmV3zI5W z_h~bbf*8sVn;c`998G>+=d!vN6kVXR_qYX;JR2C;vRObuL(YP&NRMYR6WC9yWJv3; zyDZfs)zQ(zP`>`n%|5oN7N4|>ox>-HDR*o4vyKi00OUtqBqTTWOC3Yq&oM5Mu$b}f zrDzQ*GN@&ru6;E(Z;9UN4Mr>McXV&9Fax*q#fM_+M;FWY&+oTX*gDt@-C9^=nhNmJ zszhuE{2l68taLgj^7JFs8RK32#eQh}?G6LGNB2V-nM83H5t_Rfc1<%g(a+Lo-!69T zo6VAn47S?uA6cb|##CHYyP3o7C&!i39mK|I(uLbvK?hi0(C~3+^7HOFh3C}}59li* z)`H222HJZh32PJ;dcbh2TN@B&A+!+&cl>u{hwiycm& zW2g}>XIa;oUTBDFG5CDSGy7BSaO&_3$LoEZCOonoW}|mRy@^OE;JC=%Yln!q)^v!H zR76S~ zt~tRp4Fa-#?JVF02wuA5=~Lh-^m{JyFR<+yBqeQZZ2FDLJx>`ea&hO{;$xGd-h`^z zdg!$LmUiP?zYCgwtt3vWsjL2sjm_{Rb=ad*4~Yg84)iY&>uYcB99YnCXuw-sTJn-l zdBwq$Hg*s>^iDILbXN}Zd+m_FVa+Yy6IVw;zk~77u&#~{G8PCzx9h65Zp?E}6+Y?7 zYe+G|sa$&%nM8B}nYu)P_Ax`AnFO%<-+u7%Sr|+0hv`au{LdcYiK+mRU_O4le{$fI zUh(@~p5%RE)B1$cqM@ae+_AYDDFbK4Hx7n3vlVHmZ%X-tH-!ys?ld>k z%&ly=&9VWf0iu};8R&Hn9~FFu!{3ZHQg*bryDf41TBDGf!!&eZ;uv{r`Q|yMVrGs! zGVi*|mi$qG_!E~;r(o5J@9IEP~ml27*fY_Oa}!s2&9Sml^K_X;j%dLX+)xs_%{_-Yy0e{bo}Re-Gdi(>aL{AU#EA+mPK5;(0k}v?AojRBx4Mhe1%c!) zz8TtOIQdOo(|CvXv!(}ZAe4Arf{AYij1#+o+bR3?gOEQUoCweyu-5HuL>}hk6C@a} zydLpLU&~cSA-F7x2BbZ(S3NkCy5^Yx>vQJTXat&tcI5FO=a$w!UjALFRsOoNlD0Ng zk3TcwIWZ277K5sq&H@x+TCkE+=RnmJ0r|x zFU0|`IqtfqQ8OoZKch+eDCtuEUh#Tg^sdO!k<($_?fm9;$1g|!m%Psb^)}I}#tkh^ zOSnlyUbx;8QU1DW6ipJ$ANim)SO_x2ANUkWZXVuA+;VcrD<$^}pAS>ygrxl$KLr_+ zS0IBr`#Jm_mboCp6k>OVYHV`Gbt#JVPm};c`M+Z1|2}B&KYmp%N~I+WHqe!8g395h z4B6$K2~C9m)5t~V)xQY8Tn>8(={Hms*G`r|-R}kO1Jq~X$?wQZCdXL8Kye`VdH&I& z{_pa~=~Fw`t^ysgCh|6AVanVB&A)I|6L;m_l%?!Q-qUkPqy}-0s2n|QhtaB{7X2^WIPE_TdyEmKk3;Z99z9cOa?aoV!tZkE z-ZbpIU0u-oOs_K^CHHJKEW?A1I}2EwA?ps#B(WKE}&42OUp3;d{SG zD7&7hzfH2WeqiR9XO@RWKBiZeqMYk|y3DmcRLYQ$w5@DW{D^|AFQx8sywZhF{E>L2 zbLBI61OZ>-CTPNg_G+V*=|;8UcP=4|kKm^$6D4Qo=3G@(bs`=corfZlIsTNTU3FMV z67|30G=9QhE&1&lRJ2sqCrfHHy}yF%(%j+tTj25iNXIHrY-t|P>-@cTs@5h@?4iy> z+AtEso4hL3an-?eeTCk{_f46zqYA~k!h*br&kCyTj?g|TR%Y+y-wo+hSyl^stOr@E z`@N!RA*A(hy|P>|x97P07nV2ge?01;b>-o6EMY! z#@#aTBFr$pY?sWA7nzXGvRto=ZwXQ~e?GIFRz{m4x@1OIV!(4}50o@ynR9iu+ZyVJ zQMFRb)){~ne8?~$E!f&j<90k?v}P@?tgZn)*>uouV>cD~YWd)b)M#G8Wp-$aG&Ik1 z9;>~-Rmo8-3=BhB!u40B;#v~Tc{_T$Q{}p7sHmw9nugR!p7K8YvogG?R&Kr@c=+^ytunRv71|I!f=2KI+F-!+4;Q zrclTnR?gy!^IavId1)h~YfLfkqaP;zSgAQJVP8CLrOd+{CHo{k6&5Y@S8uc5qtQoU z-sVK1S3V;%j{oELiEo&1nD@Zukh^5})qG6hoi*5If-MQtM0u(fBGjvZP;Dq=GhCC~ zoy_Z2I@UKW2yay;UT1IvOGrB?`O~0?nOe;=uTfE?Uw$2FIq}f)ws+Hbsd-HO%w>mI z-zyd_Y(qjrB7{kxUk0^zgZEbhgN22SZv)M~wzR!C*4yugKYv?}2YpdK!aLp=m8(Ce z`^CMXhly#Y@`jF(S?6zD;XtLn!hoe`3Zkg&Z%k=f#hq77Jj)#zOiyI&IX>Lc(BRc; zlVj#5y|xa}$BN^4{kBrQSf68K>hjZeaX%{>*h!0P3->-+CE$Fsj1h-O4km57-Ij;9 zbkfjJr4I8u(L{rBd!Ty=X$H-0dlxQl?1<#M*gq`5d)@R&@Je!NWrG>3TMUwAyIW9Z zHv65))9mZ$%C?zmadazFTq+xDm7%lwo2$htKI`>7qd15VV%e0ci!QEQ1%k z6*ks}sS$$ciS^p|hI2lFAD1vQwAi0hbHs!Vh<4%&-{8!Uos!YyEF^g5izsBOib|7o z6<_;TIjqx%&p$KMK*pMHat%6uxn(9q5jo&TB$Shl0JS>h=8 zo0wj0v{yynEd+i1$_u#Mzd&Aq(hNI~3SvM(mJc)iaKh3R3N5bQ+7s9!V?WD^;b5HX zU?f}T8lWj+KWc25j#3bXygfXGr)Q)`#Qzk!&crH)t2X>fd3^W0ulvTGq!4+_*)vfg zl&@TBKTT6oTDs1@#{(?k-DJsGvLlyzY*3>LUFoJ}QE}{1tINddRP;Rl`Rc(_&)DE2 zI|FxCpO;A=E z>45Tbj_U^k5ag%3Xw&Pdr#GkQNpSpS)XDus9mVZ?BmUEyB_D&cJi(Dtr^=g=ggli&H;s*4R;#VANnz_ri%XvT{0H zk9hAIV=ft7fB^hWY_N^YFb6|m`;RMs=B!~83IXh-KLeHn0A>ijfXPK{EU;)0|0@;I zAw&&4-T$lR3$A^ZR;&jePmze(*@$HtOzMLgi{j7uSvGEmIXc_S-a8+GKPiLMFAR(a ziG*&fm)p(gR@@YKjOna(uo4PJM)fDkms$_I^d`O6&^<3@Ic`Flpn>WHJd7~wG#8fvn)9-=Z1Pf>@1i5fp6;1Q{-L*vMdHj7D`pU{0e#N}~ zV(ZGvbuxm2VZkKtzWJT?eOdb#UG>|pV4JQbMeIYe`i*`B1*Ez3ywdYeJ+aVrBquJe$4$7QJ7Ht`SvIO$?Dc_jk4;1sCcm;IcgL)$U3z}X41Z^0a zlqrokX?>c}EsqO()6Vi|dU9|qW$ijHvB;-O$pysl~bQT5WV`eTSAHTi^{MV6<vf1@U%uq#;eAF*`q@ODZN{VCZf!g^ zFZjud)cI2{f#|$UgF1D)wf?H5`cMd!*(kJGq%9&LHXtjEFhql6RAB1b!<-I35ce1# zbQv|=lu5M^fki|H2h}h%l$*-tYLlX24x2o^w*T2~IvN0OylChMn46%uFdS@cY1T0@ z2rSJFDyaW<=jqnd#f4bgrInTW_>noy3>U--UJ*ApFa9V zD6opXK1)61l`dF_zAj2G+L+e--_JBPPM3H3jy2kQyBnt`VLq;nFOFJqLVZd5$!+tM zyHe^fDjn7_A6Y-*e$R^wP+a+@fI_B|9}a{ojcRyPr3wB88Xm%MJ7=(jMX#7A)^(Eq z@L}GaKkzhKYiq}T+9BTVo}P}5-Zyek2^)H4&Jkdt(Q(M}f^z+di9)^~ca1JAZhk%r zp>wbt7yly0G2yuBn;PkhpLZhVQ%5Vx(G%6gwY3sJ(Wxwz7F;=J+C}H$KaB zmJH0Y^|cB2W^{f-p$#=j0%<^4NK^m#vkdtFa;6`&)J{z1sqJMre`m zs7Rf+a+>~h2}jxPNcfJs%g>mkKJRHAlBv=phw1mxKbQnqlFF9eS!jo)yNAhe*Hl{_ zH|hfj{s7w&vPHTLo{;ql>D4)$b1*XIC`T975_;ZHcgf>?c8_Ti(bzT{M(Ihq)Mu}; z|2W$>KVjQ_k%Nni<(L~7M>3JvY`bd-?hQAN9qk>|tBDfQjxei9Yf3Sqg^w0&;+e^* z#i{{39;Irbqa)*Bz!|AvT|%GVKph=#|jjCl<*4VH2bSxbxNh8@2@BPSL8>a^Zx z%Xc=44kpKFtdSr4!T1%+lr)3 z6f{pEocTg!M)RAl;B_lU~0k6Is_hpi?Y)@99{Q`CKO zvzS?_sj1|CLcMbR3q~jEppX&f>x4C8Ik3!vvSLO2>(CF5#X^vsUgxUES$%24MsPWV zL-8>{dSF0zs_$)VD7%q2i?*%f$*7wUff;J~UN)$p(Er@T%F1SF_!((X4MHX0+FtZ5 zBNqjg6!mkU1`7Qm2m#E4uA$cH2?yi8gZq-IXVY$XeD1=PDaAayK|PEiB!0dz);67r zhLG6a%4%eokQ@&ZHF>$c5PJxo#(L}tjW!cY`P1JL047ka<|}p7-CTK|Ys9%`}`-sge&kfQ!F*p2Kl*7~=?DV%#+>%9w(~Pb76`8HBHZ@!gGNAwynIo?myq-A`3>F(+=ybwp#+xD zI{eSeoSvRerse9u+Zqp*yh+~d*!b>$D^pu6*W&Mww}6{HoooSTMFV_#ZcY74Qo;tn zgGpYVXn07C+N3J`QhrMFRN%@)3jjggnBU&%tc0HKUZ!=_I4R(sdnoa^I)z z;baw|2CoJiI~yRK1BjT&UqYv=F3(icj$Y299mjE|8%$65LhD=mi#~5nSHM15K4mJl z{4}ayU$q$|p=tL7O@_I5D!4XaKq$a8e8#*BQ=CQOQ7@@2oH`L&zF49veG5B?CjQSWO3t>I>Ra0J~;X-ZI(O+qt4FL=~0u;AcXVw1o`w ziLi^$XrV7GD%LN~pZQI5YxDYd|Ddg~O5RmUX(_RKzMzq9{wCxICucPVlF)j8OPGE_ zskvi(-P?RhI(HbL3@opj7z}{?)5VS0WypEa|Ag#4G#Sawhh(G0uxK{-Y@XbNgbKqI z7u;&NEOOW~ULnt#wswcGf5wJC@&d0rZ2Ew8E-I$Ix0mAJ1m|LU=gIBNwa4^_=_0tQ zx9=}*kf;kn&c1Hb%#aU{NeEI->o3Hftcq#h`@z=2;Olh3x1YW-zuf_EqUEkM6aWsO zxvhD>tz;N&$YKxld&Gs1LuAPK3wYb`lm3uP+HVqbTDr|v56a7{BHTFmE>1_FX(oo2 zq9WB6G*->!59uc+*Ij*tdAQ$yG09n2yZOAdyb=zOS?la5`kKEcp6)G~$F=)DCTHQa zn74XNFboeSE+N+3HA;*|?g?d1S4BeZ2T5uFPQ+=T0~Q; zlBaA##p{N0!6Whw<2P@rkJn#o$w zYC{R-A)TwL57U4FTpjbKeD}ya6m(zsLhLw+G$of@WuZAdx#{idp*(P*BIB>7Qg4`v z9?O0Ks3|a6TQk+LC0{D~iFJdG*0xq1Q_GO2pnRFuHfrpx#}6?uTJ-X12*B* z6GUd4ga!oxiqe=;wo>jQ+F&17>@`uP9l}DK1!Th#AM_N0S$*0Bj}$uOUa*l!Y6pF( z0xiABN135|tE~c6b~kq%P8{r93kXLL0`VW*YPrjH(P|J|OH1>Ms*|dNn|}2d9fBDh zJ-s?DjTvP-V+3HnuWjN2@wsicp3^=hrv-Q5R3C4uZR{m<4m~&IBJ54%ZYt>E{SgvM zk#1rBxGAH zn$I>x9mWLII*_smkh8I|8T~DzKy$6wPInQrp9-A%`Q>yZ;oeAgs6PS&xWsrKk3hhz z7Z!K@6MVD+PPgW_#dY28)F8|ZM?u?@mgc`HqK4m09>f3>=j`Gd9u+G-){7h~-iJKh znGUOi-6gHI7u3&bZ0Jv2^LNw9Sj)Irm%< z+bj9jyF{p%|Fa=G#_Ne=QTzX^Mo{7!X|oI6Pr@N|-_GrS)LM`ZQ=Oh+@cRC{HLxeHa^RFPZ>~bU#D04nOQo9*C_qitx_kw?aKd& zy1!ceuS%)^@0kA&(R5k5z-tW)2%Pokt_~_H?)iTGzv{h~6Rf|0CksYu8J{b-pu}Iz zdUU@vssHm+srR&^hfT^`LxoXEiZ)R6Ma`U1by&0E(ku<)-{N9ojV&!5s;vJE9`n&7 zao_+d$*l4G}iZ#}Xln zUp?~Ajr)ZNdqbF zh`1OC_*oNs3N)Zq;(k9yOqC2uL~&vAPvm1zA&YdlA`NIwuBT(6T;lI?>4<-oVBFIq z#mcUfGHlXsQVxQgcRo1if(l_!?1TT99q*cL9qTbz#>kSyYR3N3+Jxsp1vRBxp9C_7 z;njz!Yi60eV9eya#WUjBnK=uOlzOMZtG`Y6m(I(eZY`l19Ef0eUX^k)&9HMs#njzH z##2l~o%J(1`g^SL{^g0JAXt5!C#M@a(a~>rnH>v-)a~kW);d!^mk7rQNa)t7pguqm zoKhyFc654)b$3>4M_XLk{`C7P=913$Z?FgjF$kn>s{5#Z@)20I { + this.updateCalcData(); + }); + } + + updateCalcData() { + const {data} = this.props.record; + // Value to display on the widget + const foundElement = (data.price_compliance_data || []).find( + (element) => element.tier === data.price_compliance_tier + ); + this.calcData.price_compliance_tier_display = foundElement + ? foundElement.display[0] + : null; + this.calcData.currency_symbol = foundElement + ? foundElement.currency_symbol + : null; + this.calcData.product_base_uom = foundElement + ? foundElement.product_base_uom + : null; + // Get Tier 1 compliance data + const foundL1Element = (data.price_compliance_data || []).find( + (element) => element.tier === "t1" + ); + this.calcData.product_price_in_base_uom = foundL1Element + ? foundL1Element.price[1] + : null; + } + + showPopup(ev) { + this.updateCalcData(); + this.closePopover = this.popover.add( + ev.currentTarget, + this.constructor.components.Popover, + { + bus: this.bus, + record: this.props.record, + calcData: this.calcData, + }, + { + position: "top", + // Ensure popup full width on kanban + popoverClass: "mw-100", + } + ); + this.bus.addEventListener("close-popover", this.closePopover); + } +} + +PriceComplianceTierWidget.components = {Popover: PriceComplianceTierPopover}; +PriceComplianceTierWidget.template = "sale_price_compliance.PriceComplianceTier"; + +registry + .category("view_widgets") + .add("price_compliance_tier_widget", PriceComplianceTierWidget); diff --git a/sale_price_compliance/static/src/widgets/price_compliance_level_widget.xml b/sale_price_compliance/static/src/widgets/price_compliance_level_widget.xml new file mode 100644 index 00000000000..9cbdaa0937c --- /dev/null +++ b/sale_price_compliance/static/src/widgets/price_compliance_level_widget.xml @@ -0,0 +1,119 @@ + + + diff --git a/sale_price_compliance/tests/__init__.py b/sale_price_compliance/tests/__init__.py new file mode 100644 index 00000000000..3aa39994b40 --- /dev/null +++ b/sale_price_compliance/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_sale_price_compliance_constraints +from . import test_sale_price_compliance diff --git a/sale_price_compliance/tests/test_sale_price_compliance.py b/sale_price_compliance/tests/test_sale_price_compliance.py new file mode 100644 index 00000000000..4d07eef6115 --- /dev/null +++ b/sale_price_compliance/tests/test_sale_price_compliance.py @@ -0,0 +1,168 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from odoo.fields import Command +from odoo.tests.common import TransactionCase + + +class TestPriceCompliance(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "SPC Partner"}) + # Enable groups Pricelist and discount groups + cls.env.user.groups_id += cls.env.ref("product.group_product_pricelist") + cls.env.user.groups_id += cls.env.ref("product.group_discount_per_so_line") + # Company + cls.env.company.write( + { + "use_price_compliance_threshold": True, + "price_compliance_threshold_t1": 0.05, + "price_compliance_threshold_t2": 0.10, + "price_compliance_threshold_t3": 0.15, + } + ) + # Category + cls.product_category = cls.env["product.category"].create( + { + "name": "SPC Category", + "use_price_compliance_threshold": True, + "price_compliance_threshold_t1": 0.20, + "price_compliance_threshold_t2": 0.25, + "price_compliance_threshold_t3": 0.30, + } + ) + # Product: Product with thresholds directly + cls.product_pct = cls.env["product.product"].create( + { + "name": "PCT Product", + "detailed_type": "service", + "list_price": 10.0, + "use_price_compliance_threshold": True, + "price_compliance_threshold_t1": 0.35, + "price_compliance_threshold_t2": 0.40, + "price_compliance_threshold_t3": 0.45, + } + ) + # Product: Product with company thresholds + cls.product_company_pct = cls.env["product.product"].create( + { + "name": "PCT Company Product", + "detailed_type": "service", + "list_price": 10.0, + } + ) + # Product: Product with category thresholds + cls.product_categ_pct = cls.env["product.product"].create( + { + "name": "PCT Categ Product", + "detailed_type": "service", + "list_price": 10.0, + "categ_id": cls.product_category.id, + } + ) + # Product: Product with category thresholds and pricelist + cls.product_pricelist_pct = cls.env["product.product"].create( + { + "name": "PCT Pricelist Product", + "detailed_type": "service", + "list_price": 10.0, + "categ_id": cls.product_category.id, + } + ) + # Pricelist + cls.pricelist = ( + cls.env["product.pricelist"] + .with_company(cls.env.company) + .create( + { + "name": "PCT Pricelist", + "currency_id": cls.env.company.currency_id.id, + "item_ids": [ + Command.create( + { + "compute_price": "percentage", + "percent_price": 50.0, + "applied_on": "0_product_variant", + "product_id": cls.product_pricelist_pct.id, + } + ) + ], + } + ) + ) + + def _create_sale_order_line(self, product, pricelist=False): + sale = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [Command.create({"product_id": product.id})], + } + ) + if pricelist: + sale.write({"pricelist_id": pricelist.id}) + sale._recompute_prices() + return sale.order_line[0] + + def _test_price_compliance_discount_tiers(self, sale_line, **tier_data): + """Test tiers all at once. + + Tier data example: + { + "t1": (0.0, 5.0), + "t2": (5.1, 10.0), + "t3": (10.1, 15.0), + "non_compliant": (15.1,), + "pricelist": (0.0,), + } + + :param sale_line: Sale Order Line record + :param tier_data: Data containing tiers to match and discounts. + """ + for tier_name, tier_values in tier_data.items(): + for tier_val in tier_values: + with self.subTest(tier=tier_name, discount=tier_val): + sale_line.write({"discount": tier_val}) + self.assertEqual(sale_line.price_compliance_tier, tier_name) + + def test_product_threshold(self): + """Test product thresholds""" + self._test_price_compliance_discount_tiers( + self._create_sale_order_line(self.product_pct), + t1=(0.0, 35.0), + t2=(35.1, 40.0), + t3=(40.1, 45.0), + non_compliant=(45.1,), + ) + + def test_product_company_threshold(self): + """Test product company thresholds""" + self._test_price_compliance_discount_tiers( + self._create_sale_order_line(self.product_company_pct), + t1=(0.0, 5.0), + t2=(5.1, 10.0), + t3=(10.1, 15.0), + non_compliant=(15.1,), + ) + + def test_product_category_threshold(self): + """Test product category thresholds""" + self._test_price_compliance_discount_tiers( + self._create_sale_order_line(self.product_categ_pct), + t1=(0.0, 20.0), + t2=(20.1, 25.0), + t3=(25.1, 30.0), + non_compliant=(30.1,), + ) + + def test_product_pricelist_threshold(self): + """Test product pricelist thresholds""" + # Because pricelist applies 50% discount directly inside the price, + # any discount should go out of Tier ranges and be Non Compliant + self._test_price_compliance_discount_tiers( + self._create_sale_order_line( + self.product_pricelist_pct, pricelist=self.pricelist + ), + pricelist=(0.0,), # Initial price + non_compliant=(1.0,), # Any extra discount + ) diff --git a/sale_price_compliance/tests/test_sale_price_compliance_constraints.py b/sale_price_compliance/tests/test_sale_price_compliance_constraints.py new file mode 100644 index 00000000000..18039b7f9fa --- /dev/null +++ b/sale_price_compliance/tests/test_sale_price_compliance_constraints.py @@ -0,0 +1,174 @@ +# Copyright 2026 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0) + +from psycopg2 import IntegrityError + +from odoo.tests.common import TransactionCase, tagged +from odoo.tools import mute_logger + + +@tagged("post_install", "-at_install") +class TestPriceComplianceConstraints(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create a clean company for testing + cls.company = cls.env["res.company"].create( + { + "name": "Test Company SQL Constraints", + } + ) + + def test_valid_cases(self): + """Success Case: Positive values and no gaps.""" + # Case 1: All values are zero [0, 0, 0] + self.company.write( + { + "use_price_compliance_threshold": True, + "price_compliance_threshold_t1": 0.0, + "price_compliance_threshold_t2": 0.0, + "price_compliance_threshold_t3": 0.0, + } + ) + # Case 2: T1 has value, others are zero (No gaps) [10%, 0%, 0%] + self.company.write({"price_compliance_threshold_t1": 0.1}) + # Case 3: T1 and T2 have values (No gaps) [10%, 5%, 0%] + self.company.write({"price_compliance_threshold_t2": 0.05}) + # Case 4: T1 and T2 and T3 have values (No gaps) [10%, 5%, 1%] + self.company.write({"price_compliance_threshold_t3": 0.01}) + + @mute_logger("odoo.sql_db") + def test_constraint_positive(self): + """Expected Failure: Negative numbers are not allowed if use thresholds.""" + with self.assertRaises(IntegrityError): + self.company.write( + { + "use_price_compliance_threshold": True, + "price_compliance_threshold_t1": -0.05, # Negative value + "price_compliance_threshold_t2": 0.0, + "price_compliance_threshold_t3": 0.0, + } + ) + + @mute_logger("odoo.sql_db") + def test_constraint_le_1(self): + """Expected Failure: High numbers are not allowed if use thresholds.""" + with self.assertRaises(IntegrityError): + self.company.write( + { + "use_price_compliance_threshold": True, + "price_compliance_threshold_t1": 1.01, # > 1.0 value + "price_compliance_threshold_t2": 0.0, + "price_compliance_threshold_t3": 0.0, + } + ) + + @mute_logger("odoo.sql_db") + def test_constraint_gaps_start(self): + """Expected Failure: Gap at the beginning (T1=0, T2>0).""" + with self.assertRaises(IntegrityError): + self.company.write( + { + "use_price_compliance_threshold": True, + "price_compliance_threshold_t1": 0.0, # Gap here + "price_compliance_threshold_t2": 0.05, + "price_compliance_threshold_t3": 0.0, + } + ) + + @mute_logger("odoo.sql_db") + def test_constraint_gaps_middle(self): + """Expected Failure: Gap in the middle (T2=0, T3>0).""" + with self.assertRaises(IntegrityError): + self.company.write( + { + "use_price_compliance_threshold": True, + "price_compliance_threshold_t1": 0.1, + "price_compliance_threshold_t2": 0.0, # Gap here + "price_compliance_threshold_t3": 0.05, + } + ) + + def test_ignore_constraint_if_disabled(self): + """Success Case: If the check is disabled, allow anything.""" + # Even with negatives or gaps, if 'use_...' is False, it should save + self.company.write( + { + "use_price_compliance_threshold": False, + "price_compliance_threshold_t1": -0.1, # Negative + "price_compliance_threshold_t2": 0.0, + "price_compliance_threshold_t3": 0.05, # Gap + } + ) + + def test_default_texts_parameter(self): + """Check if default texts are used if parameter is not set.""" + # Set custom texts + selection_texts = self.env[ + "sale.order.line" + ]._get_price_compliance_selection_tiers_text() + default_texts = self.env[ + "sale.order.line" + ]._get_price_compliance_selection_tiers_text_default() + self.assertEqual(selection_texts[0][1], default_texts[0][1]) + self.assertEqual(selection_texts[1][1], default_texts[1][1]) + self.assertEqual(selection_texts[2][1], default_texts[2][1]) + self.assertEqual(selection_texts[3][1], default_texts[3][1]) + self.assertEqual(selection_texts[4][1], default_texts[4][1]) + + def test_custom_texts_parameter(self): + """Check if custom texts are used if parameter is set.""" + # Set custom texts + self.env["ir.config_parameter"].sudo().set_param( + "sale_price_compliance.price_compliance_selection_tiers_text", + "{'t1': 'T. Gold', 't2': 'T. Silver', 'pricelist': 'T. Agreeed Price'}", + ) + selection_texts = self.env[ + "sale.order.line" + ]._get_price_compliance_selection_tiers_text() + default_texts = self.env[ + "sale.order.line" + ]._get_price_compliance_selection_tiers_text_default() + self.assertEqual(selection_texts[0][1], "T. Gold") + self.assertEqual(selection_texts[1][1], "T. Silver") + self.assertEqual(selection_texts[2][1], default_texts[2][1]) + self.assertEqual(selection_texts[3][1], default_texts[3][1]) + self.assertEqual(selection_texts[4][1], "T. Agreeed Price") + + def test_custom_icons_parameter(self): + """Check if custom icons are used if parameter is set.""" + # Set custom icons + self.env["ir.config_parameter"].sudo().set_param( + "sale_price_compliance.price_compliance_selection_tiers_icon", + "{'t1': '🟢', 't2': '🟡', 't3': '🟠', 'pricelist': '🟣'}", + ) + selection_icons = self.env[ + "sale.order.line" + ]._get_price_compliance_selection_tiers_icon_color() + default_icons = self.env[ + "sale.order.line" + ]._get_price_compliance_selection_tiers_icon_color_default() + self.assertEqual(selection_icons[0][1], "🟢") + self.assertEqual(selection_icons[1][1], "🟡") + self.assertEqual(selection_icons[2][1], "🟠") + self.assertEqual(selection_icons[3][1], default_icons[3][1]) + self.assertEqual(selection_icons[4][1], "🟣") + + def test_custom_texts_wrong_parameter(self): + """Check if custom texts are used if parameter is not correctly set.""" + # Set custom texts + self.env["ir.config_parameter"].sudo().set_param( + "sale_price_compliance.price_compliance_selection_tiers_text", + "test wrong parameter", + ) + selection_texts = self.env[ + "sale.order.line" + ]._get_price_compliance_selection_tiers_text() + default_texts = self.env[ + "sale.order.line" + ]._get_price_compliance_selection_tiers_text_default() + self.assertEqual(selection_texts[0][1], default_texts[0][1]) + self.assertEqual(selection_texts[1][1], default_texts[1][1]) + self.assertEqual(selection_texts[2][1], default_texts[2][1]) + self.assertEqual(selection_texts[3][1], default_texts[3][1]) + self.assertEqual(selection_texts[4][1], default_texts[4][1]) diff --git a/sale_price_compliance/views/product_category_view.xml b/sale_price_compliance/views/product_category_view.xml new file mode 100644 index 00000000000..a1f0aa202c9 --- /dev/null +++ b/sale_price_compliance/views/product_category_view.xml @@ -0,0 +1,59 @@ + + + + + product.category.form + product.category + + + + + + + + + + + + + + + product.category.search + product.category + + + + + + + + + diff --git a/sale_price_compliance/views/product_product_view.xml b/sale_price_compliance/views/product_product_view.xml new file mode 100644 index 00000000000..25c6111d454 --- /dev/null +++ b/sale_price_compliance/views/product_product_view.xml @@ -0,0 +1,42 @@ + + + + + product.product.view.form.easy + product.product + + + + + + + + + + + + + diff --git a/sale_price_compliance/views/product_template_view.xml b/sale_price_compliance/views/product_template_view.xml new file mode 100644 index 00000000000..e080bf14d1d --- /dev/null +++ b/sale_price_compliance/views/product_template_view.xml @@ -0,0 +1,59 @@ + + + + + product.template.common.form + product.template + + + + + + + + + + + + + + + product.template.search + product.template + + + + + + + + + diff --git a/sale_price_compliance/views/res_config_settings_view.xml b/sale_price_compliance/views/res_config_settings_view.xml new file mode 100644 index 00000000000..0d2ee184598 --- /dev/null +++ b/sale_price_compliance/views/res_config_settings_view.xml @@ -0,0 +1,88 @@ + + + + + res.config.settings.view.form.inherit.price.compliance + res.config.settings + + + +

+
+ +
+
+
+
+ + + + diff --git a/sale_price_compliance/views/sale_order_line_view.xml b/sale_price_compliance/views/sale_order_line_view.xml new file mode 100644 index 00000000000..22d16c80d7f --- /dev/null +++ b/sale_price_compliance/views/sale_order_line_view.xml @@ -0,0 +1,32 @@ + + + + + sale.order.line.tree + sale.order.line + + + + + + + + + + + sale.order.line.select + sale.order.line + + + + + + + + diff --git a/sale_price_compliance/views/sale_order_view.xml b/sale_price_compliance/views/sale_order_view.xml new file mode 100644 index 00000000000..8c7ddc9d62e --- /dev/null +++ b/sale_price_compliance/views/sale_order_view.xml @@ -0,0 +1,60 @@ + + + + + sale.order.form + sale.order + + + + + +
+ + +
+
+
+
+ + + + + + + + + + + + +
+ +
+
+
+
+
diff --git a/setup/sale_price_compliance/odoo/addons/sale_price_compliance b/setup/sale_price_compliance/odoo/addons/sale_price_compliance new file mode 120000 index 00000000000..ef4cdc32fbd --- /dev/null +++ b/setup/sale_price_compliance/odoo/addons/sale_price_compliance @@ -0,0 +1 @@ +../../../../sale_price_compliance \ No newline at end of file diff --git a/setup/sale_price_compliance/setup.py b/setup/sale_price_compliance/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/sale_price_compliance/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)