From f3c23d107c92737dc43a04195026e59c3e093d0c Mon Sep 17 00:00:00 2001 From: david Date: Mon, 26 Jul 2021 15:07:34 +0200 Subject: [PATCH 01/18] [ADD] sale_coupon_limit: New module TT30847 --- sale_loyalty_limit/README.rst | 118 +++++ sale_loyalty_limit/__init__.py | 1 + sale_loyalty_limit/__manifest__.py | 15 + sale_loyalty_limit/i18n/sale_coupon_limit.pot | 190 ++++++++ sale_loyalty_limit/models/__init__.py | 3 + sale_loyalty_limit/models/sale_coupon.py | 58 +++ .../models/sale_coupon_program.py | 71 +++ sale_loyalty_limit/models/sale_coupon_rule.py | 67 +++ sale_loyalty_limit/readme/CONFIGURE.rst | 16 + sale_loyalty_limit/readme/CONTRIBUTORS.rst | 4 + sale_loyalty_limit/readme/DESCRIPTION.rst | 3 + sale_loyalty_limit/readme/USAGE.rst | 8 + .../security/ir.model.access.csv | 3 + .../static/description/icon.png | Bin 0 -> 6204 bytes .../static/description/icon.svg | 141 ++++++ .../static/description/index.html | 459 ++++++++++++++++++ sale_loyalty_limit/tests/__init__.py | 1 + .../tests/test_sale_coupon_limit.py | 314 ++++++++++++ .../views/sale_coupon_program_views.xml | 28 ++ 19 files changed, 1500 insertions(+) create mode 100644 sale_loyalty_limit/README.rst create mode 100644 sale_loyalty_limit/__init__.py create mode 100644 sale_loyalty_limit/__manifest__.py create mode 100644 sale_loyalty_limit/i18n/sale_coupon_limit.pot create mode 100644 sale_loyalty_limit/models/__init__.py create mode 100644 sale_loyalty_limit/models/sale_coupon.py create mode 100644 sale_loyalty_limit/models/sale_coupon_program.py create mode 100644 sale_loyalty_limit/models/sale_coupon_rule.py create mode 100644 sale_loyalty_limit/readme/CONFIGURE.rst create mode 100644 sale_loyalty_limit/readme/CONTRIBUTORS.rst create mode 100644 sale_loyalty_limit/readme/DESCRIPTION.rst create mode 100644 sale_loyalty_limit/readme/USAGE.rst create mode 100644 sale_loyalty_limit/security/ir.model.access.csv create mode 100644 sale_loyalty_limit/static/description/icon.png create mode 100644 sale_loyalty_limit/static/description/icon.svg create mode 100644 sale_loyalty_limit/static/description/index.html create mode 100644 sale_loyalty_limit/tests/__init__.py create mode 100644 sale_loyalty_limit/tests/test_sale_coupon_limit.py create mode 100644 sale_loyalty_limit/views/sale_coupon_program_views.xml diff --git a/sale_loyalty_limit/README.rst b/sale_loyalty_limit/README.rst new file mode 100644 index 000000000..505fd750f --- /dev/null +++ b/sale_loyalty_limit/README.rst @@ -0,0 +1,118 @@ +================= +Sale Coupon Limit +================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--promotion-lightgray.png?logo=github + :target: https://github.com/OCA/sale-promotion/tree/13.0/sale_coupon_limit + :alt: OCA/sale-promotion +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sale-promotion-13-0/sale-promotion-13-0-sale_coupon_limit + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/296/13.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to configure a limit on the times a promotion can be applied. Two +limits can be configured: customer and salesman. Those limits apply to either programs +or coupons. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure customer limits: + +#. Go to *Sales > Catalog > Coupon Programs* and select or create a new one. +#. Set the *Maximum Customer Applications* to the number of times a coupon can be used + by a customer. + +NOTE: The customer limit is applied at commercial entity level, not for each contact +inside the entity. + +To configure salesmen limits: + +#. Go to *Sales > Catalog > Coupon Programs* and select or create a new one. +#. Add salesmen to the *Salesmen Limits* list and their maximum number of applications. +#. You can add different limits to different salesmen groups. +#. If you want to constrain the use of the promotion to the salesmen list, set the + option *Strict limit* on, so any other salesman won't be able to apply the promotion. + +Usage +===== + +Once the program limits are configured, apply the programs as usual in your sale orders. + +Once the limit for a customer or a salesman is reached, if we try to apply a promotion: + +- A code promotion will raise an error. +- A program with no code won't be applied. +- A coupon belonging to a limited program will raise an error. +- A promotion applied on the next order won't generate the coupon. + +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Tecnativa + +Contributors +~~~~~~~~~~~~ + +* `Tecnativa `_: + + * Pedro M. Baeza + * David Vidal + +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-chienandalu| image:: https://github.com/chienandalu.png?size=40px + :target: https://github.com/chienandalu + :alt: chienandalu + +Current `maintainer `__: + +|maintainer-chienandalu| + +This module is part of the `OCA/sale-promotion `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_loyalty_limit/__init__.py b/sale_loyalty_limit/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/sale_loyalty_limit/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_loyalty_limit/__manifest__.py b/sale_loyalty_limit/__manifest__.py new file mode 100644 index 000000000..45cc7b961 --- /dev/null +++ b/sale_loyalty_limit/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2021 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Sale Coupon Limit", + "summary": "Restrict number of promotions per customer or salesman", + "version": "13.0.1.0.0", + "development_status": "Production/Stable", + "category": "Sale", + "website": "https://github.com/OCA/sale-promotion", + "author": "Tecnativa, Odoo Community Association (OCA)", + "maintainers": ["chienandalu"], + "license": "AGPL-3", + "depends": ["sale_coupon", "sale_commercial_partner"], + "data": ["views/sale_coupon_program_views.xml", "security/ir.model.access.csv"], +} diff --git a/sale_loyalty_limit/i18n/sale_coupon_limit.pot b/sale_loyalty_limit/i18n/sale_coupon_limit.pot new file mode 100644 index 000000000..4da7b3458 --- /dev/null +++ b/sale_loyalty_limit/i18n/sale_coupon_limit.pot @@ -0,0 +1,190 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_coupon_limit +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \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_coupon_limit +#: model:ir.model,name:sale_coupon_limit.model_sale_coupon_rule_salesmen_limit +msgid "Coupon Rule Salesmen limits" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__create_uid +msgid "Created by" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__create_date +msgid "Created on" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__display_name +msgid "Display Name" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__id +msgid "ID" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,help:sale_coupon_limit.field_sale_coupon_program__rule_salesmen_strict_limit +#: model:ir.model.fields,help:sale_coupon_limit.field_sale_coupon_rule__rule_salesmen_strict_limit +msgid "" +"If marked, promotion will only be allowed for the list of salesmen with " +"their quantities" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit____last_update +msgid "Last Modified on" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__write_date +msgid "Last Updated on" +msgstr "" + +#. module: sale_coupon_limit +#: model_terms:ir.ui.view,arch_db:sale_coupon_limit.sale_coupon_program_view_form_common +msgid "Max" +msgstr "" + +#. module: sale_coupon_limit +#: model_terms:ir.ui.view,arch_db:sale_coupon_limit.sale_coupon_program_view_form_common +msgid "Max. Customer Applications" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_program__rule_max_customer_application +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule__rule_max_customer_application +msgid "Maximum Customer Applications" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__rule_max_salesman_application +msgid "Maximum Salesman Applications" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,help:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__rule_max_salesman_application +msgid "Maximum times a salesman can apply a program. 0 for no limit." +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,help:sale_coupon_limit.field_sale_coupon_program__rule_salesmen_limit_ids +#: model:ir.model.fields,help:sale_coupon_limit.field_sale_coupon_rule__rule_salesmen_limit_ids +msgid "Maximum times salesmen can apply a program. Empty for no limit." +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,help:sale_coupon_limit.field_sale_coupon_program__rule_max_customer_application +#: model:ir.model.fields,help:sale_coupon_limit.field_sale_coupon_rule__rule_max_customer_application +msgid "" +"Maximum times that a program can be applied to a customer. 0 for no limit." +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__rule_id +msgid "Rule" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model,name:sale_coupon_limit.model_sale_coupon +msgid "Sales Coupon" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model,name:sale_coupon_limit.model_sale_coupon_program +msgid "Sales Coupon Program" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model,name:sale_coupon_limit.model_sale_coupon_rule +msgid "Sales Coupon Rule" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__rule_user_id +msgid "Salesman" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_program__rule_salesmen_limit_ids +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule__rule_salesmen_limit_ids +msgid "Salesmen Limits" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_program__rule_salesmen_limit_count +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule__rule_salesmen_limit_count +msgid "Salesmen maximum promotions" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_program__rule_salesmen_strict_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule__rule_salesmen_strict_limit +msgid "Strict limit" +msgstr "" + +#. module: sale_coupon_limit +#: code:addons/sale_coupon_limit/models/sale_coupon_program.py:0 +#, python-format +msgid "" +"This promo code was already applied %s times for this customer and there's " +"an stablished limit of %s for this promotion." +msgstr "" + +#. module: sale_coupon_limit +#: code:addons/sale_coupon_limit/models/sale_coupon_program.py:0 +#, python-format +msgid "" +"This promo code was already applied %s times for this salesman and there's " +"an stablished limit of %s for this promotion." +msgstr "" + +#. module: sale_coupon_limit +#: code:addons/sale_coupon_limit/models/sale_coupon.py:0 +#: code:addons/sale_coupon_limit/models/sale_coupon_program.py:0 +#, python-format +msgid "This promotion is restricted to the listed salesmen." +msgstr "" + +#. module: sale_coupon_limit +#: code:addons/sale_coupon_limit/models/sale_coupon.py:0 +#: code:addons/sale_coupon_limit/models/sale_coupon_program.py:0 +#, python-format +msgid "" +"This promotion was already applied %s times for this customer and there's an" +" stablished limit of %s." +msgstr "" + +#. module: sale_coupon_limit +#: code:addons/sale_coupon_limit/models/sale_coupon.py:0 +#: code:addons/sale_coupon_limit/models/sale_coupon_program.py:0 +#, python-format +msgid "" +"This promotion was already applied %s times for this salesman and there's an" +" stablished limit of %s." +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.constraint,message:sale_coupon_limit.constraint_sale_coupon_rule_salesmen_limit_user_id_uniq +msgid "This salesman limit is already configured" +msgstr "" diff --git a/sale_loyalty_limit/models/__init__.py b/sale_loyalty_limit/models/__init__.py new file mode 100644 index 000000000..ec403ad49 --- /dev/null +++ b/sale_loyalty_limit/models/__init__.py @@ -0,0 +1,3 @@ +from . import sale_coupon_rule +from . import sale_coupon_program +from . import sale_coupon diff --git a/sale_loyalty_limit/models/sale_coupon.py b/sale_loyalty_limit/models/sale_coupon.py new file mode 100644 index 000000000..cf48faae2 --- /dev/null +++ b/sale_loyalty_limit/models/sale_coupon.py @@ -0,0 +1,58 @@ +# Copyright 2021 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _, models + + +class SaleCoupon(models.Model): + _inherit = "sale.coupon" + + def _check_coupon_code(self, order): + """Add customer and salesmen limit to program coupons. Check the error strings + for a detailed case detail.""" + message = super()._check_coupon_code(order) + if message: + return message + domain = [ + ("order_id", "!=", order.id), + ("program_id", "=", self.program_id.id), + ("state", "=", "used"), + ] + # Customer limit rules + if self.program_id.rule_max_customer_application: + coupons_count = self.search_count( + domain + + [ + ( + "sales_order_id.commercial_partner_id", + "=", + order.commercial_partner_id.id, + ) + ] + ) + if coupons_count >= self.program_id.rule_max_customer_application: + return { + "error": _( + "This promotion was already applied %s times for this " + "customer and there's an stablished limit of %s." + ) + % (coupons_count, self.program_id.rule_max_customer_application) + } + # Salesmen limit rules + salesman_rule = self.program_id.rule_salesmen_limit_ids.filtered( + lambda x: order.user_id == x.rule_user_id + ) + if salesman_rule: + coupons_count = self.search_count( + domain + [("sales_order_id.user_id", "=", order.user_id.id)] + ) + if coupons_count >= salesman_rule.rule_max_salesman_application: + return { + "error": _( + "This promotion was already applied %s times for this " + "salesman and there's an stablished limit of %s." + ) + % (coupons_count, salesman_rule.rule_max_salesman_application) + } + if self.program_id.rule_salesmen_strict_limit and not salesman_rule: + return {"error": _("This promotion is restricted to the listed salesmen.")} + return message diff --git a/sale_loyalty_limit/models/sale_coupon_program.py b/sale_loyalty_limit/models/sale_coupon_program.py new file mode 100644 index 000000000..b620fea17 --- /dev/null +++ b/sale_loyalty_limit/models/sale_coupon_program.py @@ -0,0 +1,71 @@ +# Copyright 2021 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _, models + + +class SaleCouponProgram(models.Model): + _inherit = "sale.coupon.program" + + def _check_promo_code(self, order, coupon_code): + """Add customer and salesmen limit to program rules. Check the error strings + for a detailed case detail.""" + message = super()._check_promo_code(order, coupon_code) + if message: + return message + domain = [("id", "!=", order.id)] + ( + [("promo_code", "=", coupon_code)] + if coupon_code + else [("no_code_promo_program_ids", "in", self.ids)] + ) + # Customer limit rules + if self.rule_max_customer_application: + customer_domain = domain + [ + ("commercial_partner_id", "=", order.commercial_partner_id.id,), + ] + order_count = self.env["sale.order"].search_count(customer_domain) + limit_reached = order_count >= self.rule_max_customer_application + if limit_reached and coupon_code: + return { + "error": _( + "This promo code was already applied %s times for this " + "customer and there's an stablished limit of %s for this " + "promotion." + ) + % (order_count, self.rule_max_customer_application) + } + elif limit_reached and not coupon_code: + return { + "error": _( + "This promotion was already applied %s times for this " + "customer and there's an stablished limit of %s." + ) + % (order_count, self.rule_max_customer_application) + } + # Salesmen limit rules + salesman_rule = self.rule_salesmen_limit_ids.filtered( + lambda x: order.user_id == x.rule_user_id + ) + if salesman_rule: + salesman_domain = domain + [("user_id", "=", order.user_id.id)] + order_count = self.env["sale.order"].search_count(salesman_domain) + limit_reached = order_count >= salesman_rule.rule_max_salesman_application + if limit_reached and coupon_code: + return { + "error": _( + "This promo code was already applied %s times for this " + "salesman and there's an stablished limit of %s for this " + "promotion." + ) + % (order_count, salesman_rule.rule_max_salesman_application) + } + elif limit_reached and not coupon_code: + return { + "error": _( + "This promotion was already applied %s times for this " + "salesman and there's an stablished limit of %s." + ) + % (order_count, salesman_rule.rule_max_salesman_application) + } + if self.rule_salesmen_strict_limit and not salesman_rule: + return {"error": _("This promotion is restricted to the listed salesmen.")} + return message diff --git a/sale_loyalty_limit/models/sale_coupon_rule.py b/sale_loyalty_limit/models/sale_coupon_rule.py new file mode 100644 index 000000000..6c24309e5 --- /dev/null +++ b/sale_loyalty_limit/models/sale_coupon_rule.py @@ -0,0 +1,67 @@ +# Copyright 2021 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class SaleCouponRule(models.Model): + _inherit = "sale.coupon.rule" + + rule_max_customer_application = fields.Integer( + string="Maximum Customer Applications", + default=0, + help="Maximum times that a program can be applied to a customer. " + "0 for no limit.", + ) + rule_salesmen_limit_ids = fields.One2many( + string="Salesmen Limits", + comodel_name="sale.coupon.rule.salesmen.limit", + inverse_name="rule_id", + help="Maximum times salesmen can apply a program. Empty for no limit.", + ) + rule_salesmen_strict_limit = fields.Boolean( + default=False, + string="Strict limit", + help="If marked, promotion will only be allowed for the list of salesmen with " + "their quantities", + ) + rule_salesmen_limit_count = fields.Integer( + string="Salesmen maximum promotions", + compute="_compute_rule_salesmen_limit_count", + ) + + @api.depends("rule_salesmen_limit_ids.rule_max_salesman_application") + def _compute_rule_salesmen_limit_count(self): + for rule in self: + rule.rule_salesmen_limit_count = sum( + rule.rule_salesmen_limit_ids.mapped( + "rule_salesmen_limit_ids.rule_max_salesman_application" + ) + ) + + +class SaleCouponRuleSalesmenLimit(models.Model): + _name = "sale.coupon.rule.salesmen.limit" + _description = "Coupon Rule Salesmen limits" + + rule_id = fields.Many2one( + comodel_name="sale.coupon.rule", + auto_join=True, + required=True, + ondelete="cascade", + ) + rule_user_id = fields.Many2one( + comodel_name="res.users", string="Salesman", required=True, ondelete="cascade", + ) + rule_max_salesman_application = fields.Integer( + string="Maximum Salesman Applications", + default=0, + help="Maximum times a salesman can apply a program. 0 for no limit.", + ) + + _sql_constraints = [ + ( + "user_id_uniq", + "unique(rule_id, rule_user_id)", + "This salesman limit is already configured", + ), + ] diff --git a/sale_loyalty_limit/readme/CONFIGURE.rst b/sale_loyalty_limit/readme/CONFIGURE.rst new file mode 100644 index 000000000..9d4406438 --- /dev/null +++ b/sale_loyalty_limit/readme/CONFIGURE.rst @@ -0,0 +1,16 @@ +To configure customer limits: + +#. Go to *Sales > Catalog > Coupon Programs* and select or create a new one. +#. Set the *Maximum Customer Applications* to the number of times a coupon can be used + by a customer. + +NOTE: The customer limit is applied at commercial entity level, not for each contact +inside the entity. + +To configure salesmen limits: + +#. Go to *Sales > Catalog > Coupon Programs* and select or create a new one. +#. Add salesmen to the *Salesmen Limits* list and their maximum number of applications. +#. You can add different limits to different salesmen groups. +#. If you want to constrain the use of the promotion to the salesmen list, set the + option *Strict limit* on, so any other salesman won't be able to apply the promotion. diff --git a/sale_loyalty_limit/readme/CONTRIBUTORS.rst b/sale_loyalty_limit/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..39af65cd5 --- /dev/null +++ b/sale_loyalty_limit/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Tecnativa `_: + + * Pedro M. Baeza + * David Vidal diff --git a/sale_loyalty_limit/readme/DESCRIPTION.rst b/sale_loyalty_limit/readme/DESCRIPTION.rst new file mode 100644 index 000000000..eb1b17bba --- /dev/null +++ b/sale_loyalty_limit/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module allows to configure a limit on the times a promotion can be applied. Two +limits can be configured: customer and salesman. Those limits apply to either programs +or coupons. diff --git a/sale_loyalty_limit/readme/USAGE.rst b/sale_loyalty_limit/readme/USAGE.rst new file mode 100644 index 000000000..ba5203e57 --- /dev/null +++ b/sale_loyalty_limit/readme/USAGE.rst @@ -0,0 +1,8 @@ +Once the program limits are configured, apply the programs as usual in your sale orders. + +Once the limit for a customer or a salesman is reached, if we try to apply a promotion: + +- A code promotion will raise an error. +- A program with no code won't be applied. +- A coupon belonging to a limited program will raise an error. +- A promotion applied on the next order won't generate the coupon. diff --git a/sale_loyalty_limit/security/ir.model.access.csv b/sale_loyalty_limit/security/ir.model.access.csv new file mode 100644 index 000000000..24712cd4d --- /dev/null +++ b/sale_loyalty_limit/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_salesmen_limit_salesman,salesman,model_sale_coupon_rule_salesmen_limit,sales_team.group_sale_salesman,1,0,0,0 +access_salesmen_limit_manager,salesmen_limit manager,model_sale_coupon_rule_salesmen_limit,sales_team.group_sale_manager,1,1,1,1 diff --git a/sale_loyalty_limit/static/description/icon.png b/sale_loyalty_limit/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..07f27584aa27ae5718a8b7aa3d407de238f6323e GIT binary patch literal 6204 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sale_loyalty_limit/static/description/index.html b/sale_loyalty_limit/static/description/index.html new file mode 100644 index 000000000..d6d1ab435 --- /dev/null +++ b/sale_loyalty_limit/static/description/index.html @@ -0,0 +1,459 @@ + + + + + + +Sale Coupon Limit + + + +
+

Sale Coupon Limit

+ + +

Production/Stable License: AGPL-3 OCA/sale-promotion Translate me on Weblate Try me on Runbot

+

This module allows to configure a limit on the times a promotion can be applied. Two +limits can be configured: customer and salesman. Those limits apply to either programs +or coupons.

+

Table of contents

+ +
+

Configuration

+

To configure customer limits:

+
    +
  1. Go to Sales > Catalog > Coupon Programs and select or create a new one.
  2. +
  3. Set the Maximum Customer Applications to the number of times a coupon can be used +by a customer.
  4. +
+

NOTE: The customer limit is applied at commercial entity level, not for each contact +inside the entity.

+

To configure salesmen limits:

+
    +
  1. Go to Sales > Catalog > Coupon Programs and select or create a new one.
  2. +
  3. Add salesmen to the Salesmen Limits list and their maximum number of applications.
  4. +
  5. You can add different limits to different salesmen groups.
  6. +
  7. If you want to constrain the use of the promotion to the salesmen list, set the +option Strict limit on, so any other salesman won’t be able to apply the promotion.
  8. +
+
+
+

Usage

+

Once the program limits are configured, apply the programs as usual in your sale orders.

+

Once the limit for a customer or a salesman is reached, if we try to apply a promotion:

+
    +
  • A code promotion will raise an error.
  • +
  • A program with no code won’t be applied.
  • +
  • A coupon belonging to a limited program will raise an error.
  • +
  • A promotion applied on the next order won’t generate the coupon.
  • +
+
+
+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Tecnativa
  • +
+
+
+

Contributors

+
    +
  • Tecnativa:
      +
    • Pedro M. Baeza
    • +
    • David Vidal
    • +
    +
  • +
+
+
+

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 maintainer:

+

chienandalu

+

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

+

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

+
+
+
+ + diff --git a/sale_loyalty_limit/tests/__init__.py b/sale_loyalty_limit/tests/__init__.py new file mode 100644 index 000000000..bc0098fed --- /dev/null +++ b/sale_loyalty_limit/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_coupon_limit diff --git a/sale_loyalty_limit/tests/test_sale_coupon_limit.py b/sale_loyalty_limit/tests/test_sale_coupon_limit.py new file mode 100644 index 000000000..18ad8ea4c --- /dev/null +++ b/sale_loyalty_limit/tests/test_sale_coupon_limit.py @@ -0,0 +1,314 @@ +# Copyright 2021 Tecnativa - David Vidal +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.exceptions import UserError +from odoo.tests import Form, common + + +class TestSaleCouponLimit(common.SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + product_obj = cls.env["product.product"] + cls.pricelist = cls.env["product.pricelist"].create( + { + "name": "Test pricelist", + "item_ids": [ + ( + 0, + 0, + { + "applied_on": "3_global", + "compute_price": "formula", + "base": "list_price", + }, + ) + ], + } + ) + cls.partner_1 = cls.env["res.partner"].create( + {"name": "Mr. Odoo", "property_product_pricelist": cls.pricelist.id} + ) + cls.partner_2 = cls.env["res.partner"].create( + {"name": "Mrs. Odoo", "property_product_pricelist": cls.pricelist.id} + ) + cls.salesman_1 = cls.env["res.users"].create( + {"name": "Salesman 1", "login": "test_salesman_1"} + ) + cls.salesman_2 = cls.env["res.users"].create( + {"name": "Salesman 2", "login": "test_salesman_2"} + ) + cls.product_a = product_obj.create({"name": "Product A", "list_price": 50}) + coupon_program_form = Form( + cls.env["sale.coupon.program"], + view="sale_coupon.sale_coupon_program_view_promo_program_form", + ) + coupon_program_form.name = "Test Coupon Limit" + # We don't want demo programs spoiling our tests + coupon_program_form.rule_products_domain = "[('id', '=', %s)]" % ( + cls.product_a.id + ) + coupon_program_form.promo_code_usage = "no_code_needed" + coupon_program_form.reward_type = "discount" + coupon_program_form.discount_apply_on = "on_order" + coupon_program_form.discount_type = "percentage" + coupon_program_form.discount_percentage = 10 + # Customer limits preceed salesmen limits + coupon_program_form.rule_max_customer_application = 2 + with coupon_program_form.rule_salesmen_limit_ids.new() as salesman_limit: + salesman_limit.rule_user_id = cls.salesman_1 + salesman_limit.rule_max_salesman_application = 2 + with coupon_program_form.rule_salesmen_limit_ids.new() as salesman_limit: + salesman_limit.rule_user_id = cls.salesman_2 + salesman_limit.rule_max_salesman_application = 2 + # With any other salesman, the limits won't apply + coupon_program_form.rule_salesmen_strict_limit = False + cls.coupon_program = coupon_program_form.save() + + def _create_sale(self, partner, salesman=False): + """Helper method to create sales in the test cases""" + sale_form = Form(self.env["sale.order"]) + sale_form.partner_id = partner + if salesman: + sale_form.user_id = salesman + with sale_form.order_line.new() as line_form: + line_form.product_id = self.product_a + line_form.product_uom_qty = 1 + return sale_form.save() + + def _apply_coupon(self, order, code, bp=False): + """Helper method to apply either coupon or progam codes. It ensures that the + UserError exception is raised as well.""" + self.env["sale.coupon.apply.code"].with_context( + active_id=order.id, bp=bp + ).create({"coupon_code": code}).process_coupon() + + def test_01_program_no_code_customer_limit(self): + """A program with no code and customer application limit won't be applied + once the limit is reached""" + sale_1 = self._create_sale(self.partner_1) + # In the case definition the program is no code, so there's nothing else to + # setup. + sale_1.recompute_coupon_lines() + self.assertTrue(bool(sale_1.order_line.filtered("is_reward_line"))) + # The limit is 2, so the promotion can be placed in a second order + sale_2 = self._create_sale(self.partner_1) + sale_2.recompute_coupon_lines() + self.assertTrue(bool(sale_2.order_line.filtered("is_reward_line"))) + # As we reach the limit, no discount will be applied + sale_3 = self._create_sale(self.partner_1) + sale_3.recompute_coupon_lines() + self.assertFalse(bool(sale_3.order_line.filtered("is_reward_line"))) + # However other partners can still enjoy the promotion + sale_4 = self._create_sale(self.partner_2) + sale_4.recompute_coupon_lines() + self.assertTrue(bool(sale_4.order_line.filtered("is_reward_line"))) + + def test_02_program_promo_code_customer_limit(self): + """A program with code and customer application limit will raise an error when + such limit is reached for a customer""" + self.coupon_program.promo_code_usage = "code_needed" + self.coupon_program.promo_code = "TEST-SALE-COUPON-LIMIT" + # We apply it once for partner 1... + sale_1 = self._create_sale(self.partner_1) + self._apply_coupon(sale_1, "TEST-SALE-COUPON-LIMIT") + self.assertTrue(bool(sale_1.order_line.filtered("is_reward_line"))) + # We apply it twice for partner 1... + sale_2 = self._create_sale(self.partner_1) + self._apply_coupon(sale_2, "TEST-SALE-COUPON-LIMIT") + self.assertTrue(bool(sale_2.order_line.filtered("is_reward_line"))) + # As we reach the limit we can't apply this code anymore + sale_3 = self._create_sale(self.partner_1) + with self.assertRaises(UserError): + self._apply_coupon(sale_3, "TEST-SALE-COUPON-LIMIT") + # We can still apply the promotion to other partners + sale_4 = self._create_sale(self.partner_2) + self._apply_coupon(sale_4, "TEST-SALE-COUPON-LIMIT") + self.assertTrue(bool(sale_4.order_line.filtered("is_reward_line"))) + + def test_03_coupon_code_customer_limit(self): + """When a coupon of a customer limited program is applied, an error will raise + when the limit is reached for a given customer.""" + # Let's generate some coupons + self.env["sale.coupon.generate"].with_context( + active_id=self.coupon_program.id + ).create({"generation_type": "nbr_coupon", "nbr_coupons": 3}).generate_coupon() + coupons = (x for x in self.coupon_program.coupon_ids) + # We apply one coupon for partner 1... + sale_1 = self._create_sale(self.partner_1) + self._apply_coupon(sale_1, next(coupons).code) + self.assertTrue(bool(sale_1.order_line.filtered("is_reward_line"))) + # We apply another coupon for partner 1... + sale_2 = self._create_sale(self.partner_1) + self._apply_coupon(sale_2, next(coupons).code) + self.assertTrue(bool(sale_2.order_line.filtered("is_reward_line"))) + # No coupon is applied. In Backend UI a Warning popup is raised + last_coupon = next(coupons) + sale_3 = self._create_sale(self.partner_1) + with self.assertRaises(UserError): + self._apply_coupon(sale_3, last_coupon.code) + # We can still apply the coupon to other partners + sale_4 = self._create_sale(self.partner_2) + self._apply_coupon(sale_4, last_coupon.code) + self.assertTrue(bool(sale_4.order_line.filtered("is_reward_line"))) + + def test_04_coupon_code_next_order_customer_limit(self): + """Coupons should not be generated for next orders above the customer limit""" + self.coupon_program.promo_applicability = "on_next_order" + # The first order generates the coupon for the next one + sale_1 = self._create_sale(self.partner_1) + sale_1.recompute_coupon_lines() + sale_1.action_confirm() + coupon_1 = sale_1.generated_coupon_ids + self.assertTrue(bool(coupon_1), "A coupon must be generated") + # Apply it and generate another coupon in a second sale and apply it again + self._apply_coupon(self._create_sale(self.partner_1), coupon_1.code) + sale_2 = self._create_sale(self.partner_1) + sale_2.recompute_coupon_lines() + sale_2.action_confirm() + coupon_2 = sale_2.generated_coupon_ids + self.assertTrue(bool(coupon_2), "A second coupon must be generated") + self._apply_coupon(self._create_sale(self.partner_1), coupon_2.code) + # Finally, we can't generate more coupons from this promotion for this partner + sale_3 = self._create_sale(self.partner_1) + sale_3.recompute_coupon_lines() + sale_3.action_confirm() + self.assertFalse( + bool(sale_3.generated_coupon_ids), + "No more coupons should be generated for this customer and program", + ) + # Other customers can still use the program + sale_4 = self._create_sale(self.partner_2) + sale_4.recompute_coupon_lines() + self.assertTrue( + bool(sale_4.generated_coupon_ids), + "A coupon should be generated for this customer", + ) + + def test_05_program_no_code_salesman_limit(self): + """A program with no code and salesman application limit won't be applied + once the limit is reached""" + # Deactivate customer limits and avoid other salesmen using this program + self.coupon_program.rule_max_customer_application = 0 + self.coupon_program.rule_salesmen_strict_limit = True + # Place the first order of salesman 1 + sale_1 = self._create_sale(self.partner_1, self.salesman_1) + sale_1.recompute_coupon_lines() + self.assertTrue(bool(sale_1.order_line.filtered("is_reward_line"))) + # The limit is 2, so the promotion can be placed in a second order + sale_2 = self._create_sale(self.partner_1, self.salesman_1) + sale_2.recompute_coupon_lines() + self.assertTrue(bool(sale_2.order_line.filtered("is_reward_line"))) + # As we reach the limit, no discount will be applied + sale_3 = self._create_sale(self.partner_1, self.salesman_1) + sale_3.recompute_coupon_lines() + self.assertFalse(bool(sale_3.order_line.filtered("is_reward_line"))) + # However the other salesman can still enjoy the promotion + sale_4 = self._create_sale(self.partner_1, self.salesman_2) + sale_4.recompute_coupon_lines() + self.assertTrue(bool(sale_4.order_line.filtered("is_reward_line"))) + # As only the salesmen in the list can use the promotion, no other can apply it + sale_5 = self._create_sale(self.partner_1) + sale_5.recompute_coupon_lines() + self.assertFalse(bool(sale_5.order_line.filtered("is_reward_line"))) + + def test_06_program_promo_code_salesman_limit(self): + """A program with code and salesman application limit will raise an error when + such limit is reached for a salesman in the list""" + # Deactivate customer limits and avoid other salesmen using this program + self.coupon_program.rule_max_customer_application = 0 + self.coupon_program.rule_salesmen_strict_limit = True + self.coupon_program.promo_code_usage = "code_needed" + self.coupon_program.promo_code = "TEST-SALE-COUPON-LIMIT" + # First salesman_1 order... + sale_1 = self._create_sale(self.partner_1, self.salesman_1) + self._apply_coupon(sale_1, "TEST-SALE-COUPON-LIMIT") + self.assertTrue(bool(sale_1.order_line.filtered("is_reward_line"))) + # Second salesman_1 order... + sale_2 = self._create_sale(self.partner_1, self.salesman_1) + self._apply_coupon(sale_2, "TEST-SALE-COUPON-LIMIT") + self.assertTrue(bool(sale_2.order_line.filtered("is_reward_line"))) + # As we reach the limit we can't apply this code anymore + sale_3 = self._create_sale(self.partner_1, self.salesman_1) + with self.assertRaises(UserError): + self._apply_coupon(sale_3, "TEST-SALE-COUPON-LIMIT") + # We can still apply the promotion with the other salesman + sale_4 = self._create_sale(self.partner_1, self.salesman_2) + self._apply_coupon(sale_4, "TEST-SALE-COUPON-LIMIT") + self.assertTrue(bool(sale_4.order_line.filtered("is_reward_line"))) + # But only the salesmen in the list can use the promotion, no other can apply it + sale_5 = self._create_sale(self.partner_1) + with self.assertRaises(UserError): + self._apply_coupon(sale_5, "TEST-SALE-COUPON-LIMIT") + + def test_07_coupon_code_salesman_limit(self): + """When a coupon of a salesmen limited program is applied, an error will raise + when the limit is reached for a given salesman.""" + # Deactivate customer limits and avoid other salesmen using this program + self.coupon_program.rule_max_customer_application = 0 + self.coupon_program.rule_salesmen_strict_limit = True + # Let's generate some coupons + self.env["sale.coupon.generate"].with_context( + active_id=self.coupon_program.id + ).create({"generation_type": "nbr_coupon", "nbr_coupons": 3}).generate_coupon() + coupons = (x for x in self.coupon_program.coupon_ids) + # We apply one coupon with salesman_1... + sale_1 = self._create_sale(self.partner_1, self.salesman_1) + self._apply_coupon(sale_1, next(coupons).code) + self.assertTrue(bool(sale_1.order_line.filtered("is_reward_line"))) + # We apply another coupon with salesman_1... + sale_2 = self._create_sale(self.partner_1, self.salesman_1) + self._apply_coupon(sale_2, next(coupons).code) + self.assertTrue(bool(sale_2.order_line.filtered("is_reward_line"))) + # An error raises as we reach the limit + last_coupon = next(coupons) + sale_3 = self._create_sale(self.partner_1, self.salesman_1) + with self.assertRaises(UserError): + self._apply_coupon(sale_3, last_coupon.code, bp=True) + # We can't apply with salesmen not in the list either + sale_4 = self._create_sale(self.partner_1) + with self.assertRaises(UserError): + self._apply_coupon(sale_4, last_coupon.code) + # We can still apply the coupon with salesman_2 + sale_5 = self._create_sale(self.partner_1, self.salesman_2) + self._apply_coupon(sale_5, last_coupon.code) + self.assertTrue(bool(sale_5.order_line.filtered("is_reward_line"))) + + def test_08_coupon_code_next_order_salesmen_limit(self): + """Coupons should not be generated for next orders above the salesman limit""" + # Deactivate customer limits and avoid other salesmen using this program + self.coupon_program.rule_max_customer_application = 0 + self.coupon_program.rule_salesmen_strict_limit = True + self.coupon_program.promo_applicability = "on_next_order" + # The first order generates the coupon for the next one + sale_1 = self._create_sale(self.partner_1, self.salesman_1) + sale_1.recompute_coupon_lines() + sale_1.action_confirm() + coupon_1 = sale_1.generated_coupon_ids + self.assertTrue(bool(coupon_1), "A coupon must be generated") + # Apply it and generate another coupon in a second sale and apply it again + self._apply_coupon( + self._create_sale(self.partner_1, self.salesman_1), coupon_1.code + ) + sale_2 = self._create_sale(self.partner_1, self.salesman_1) + sale_2.recompute_coupon_lines() + sale_2.action_confirm() + coupon_2 = sale_2.generated_coupon_ids + self.assertTrue(bool(coupon_2), "A second coupon must be generated") + self._apply_coupon( + self._create_sale(self.partner_1, self.salesman_1), coupon_2.code + ) + # Finally, we can't generate more coupons from this promotion for this partner + sale_3 = self._create_sale(self.partner_1, self.salesman_1) + sale_3.recompute_coupon_lines() + sale_3.action_confirm() + self.assertFalse( + bool(sale_3.generated_coupon_ids), + "No more coupons should be generated for this customer and program", + ) + # Other customers can still use the program + sale_4 = self._create_sale(self.partner_1, self.salesman_2) + sale_4.recompute_coupon_lines() + self.assertTrue( + bool(sale_4.generated_coupon_ids), + "A coupon should be generated for this customer", + ) diff --git a/sale_loyalty_limit/views/sale_coupon_program_views.xml b/sale_loyalty_limit/views/sale_coupon_program_views.xml new file mode 100644 index 000000000..cdd6a832e --- /dev/null +++ b/sale_loyalty_limit/views/sale_coupon_program_views.xml @@ -0,0 +1,28 @@ + + + + + sale.coupon.program + + + + + From 40f1de9847e054f0cb7901bed7b047e5de2cd7e9 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 20 Sep 2021 17:14:43 +0200 Subject: [PATCH 02/18] [IMP] sale_coupon_limit: usage info We add some compute fields to check the current promotion usage by the limited users. That allows us to refactor the check method a little bit as well unifying the logic. TT30847 --- sale_loyalty_limit/__manifest__.py | 2 +- sale_loyalty_limit/i18n/sale_coupon_limit.pot | 32 ++++++++- sale_loyalty_limit/models/sale_coupon.py | 22 +++---- .../models/sale_coupon_program.py | 32 ++++----- sale_loyalty_limit/models/sale_coupon_rule.py | 66 +++++++++++++++++-- .../tests/test_sale_coupon_limit.py | 1 + .../views/sale_coupon_program_views.xml | 20 +++++- 7 files changed, 135 insertions(+), 40 deletions(-) diff --git a/sale_loyalty_limit/__manifest__.py b/sale_loyalty_limit/__manifest__.py index 45cc7b961..6c07b44e3 100644 --- a/sale_loyalty_limit/__manifest__.py +++ b/sale_loyalty_limit/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Sale Coupon Limit", "summary": "Restrict number of promotions per customer or salesman", - "version": "13.0.1.0.0", + "version": "13.0.1.1.0", "development_status": "Production/Stable", "category": "Sale", "website": "https://github.com/OCA/sale-promotion", diff --git a/sale_loyalty_limit/i18n/sale_coupon_limit.pot b/sale_loyalty_limit/i18n/sale_coupon_limit.pot index 4da7b3458..6cd7f5b71 100644 --- a/sale_loyalty_limit/i18n/sale_coupon_limit.pot +++ b/sale_loyalty_limit/i18n/sale_coupon_limit.pot @@ -71,6 +71,11 @@ msgstr "" msgid "Max. Customer Applications" msgstr "" +#. module: sale_coupon_limit +#: model_terms:ir.ui.view,arch_db:sale_coupon_limit.sale_coupon_program_view_form_common +msgid "Max. Salesmen Applications" +msgstr "" + #. module: sale_coupon_limit #: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_program__rule_max_customer_application #: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule__rule_max_customer_application @@ -137,6 +142,12 @@ msgstr "" msgid "Salesmen maximum promotions" msgstr "" +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_program__rule_salesmen_limit_used_count +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule__rule_salesmen_limit_used_count +msgid "Salesmen promotions used" +msgstr "" + #. module: sale_coupon_limit #: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_program__rule_salesmen_strict_limit #: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule__rule_salesmen_strict_limit @@ -177,7 +188,6 @@ msgstr "" #. module: sale_coupon_limit #: code:addons/sale_coupon_limit/models/sale_coupon.py:0 -#: code:addons/sale_coupon_limit/models/sale_coupon_program.py:0 #, python-format msgid "" "This promotion was already applied %s times for this salesman and there's an" @@ -188,3 +198,23 @@ msgstr "" #: model:ir.model.constraint,message:sale_coupon_limit.constraint_sale_coupon_rule_salesmen_limit_user_id_uniq msgid "This salesman limit is already configured" msgstr "" + +#. module: sale_coupon_limit +#: model_terms:ir.ui.view,arch_db:sale_coupon_limit.sale_coupon_program_view_form_common +msgid "Total limit" +msgstr "" + +#. module: sale_coupon_limit +#: model_terms:ir.ui.view,arch_db:sale_coupon_limit.sale_coupon_program_view_form_common +msgid "Total used" +msgstr "" + +#. module: sale_coupon_limit +#: model:ir.model.fields,field_description:sale_coupon_limit.field_sale_coupon_rule_salesmen_limit__rule_times_used +msgid "Uses" +msgstr "" + +#. module: sale_coupon_limit +#: model_terms:ir.ui.view,arch_db:sale_coupon_limit.sale_coupon_program_view_form_common +msgid "used already)" +msgstr "" diff --git a/sale_loyalty_limit/models/sale_coupon.py b/sale_loyalty_limit/models/sale_coupon.py index cf48faae2..42a3d89a9 100644 --- a/sale_loyalty_limit/models/sale_coupon.py +++ b/sale_loyalty_limit/models/sale_coupon.py @@ -41,18 +41,16 @@ def _check_coupon_code(self, order): salesman_rule = self.program_id.rule_salesmen_limit_ids.filtered( lambda x: order.user_id == x.rule_user_id ) - if salesman_rule: - coupons_count = self.search_count( - domain + [("sales_order_id.user_id", "=", order.user_id.id)] - ) - if coupons_count >= salesman_rule.rule_max_salesman_application: - return { - "error": _( - "This promotion was already applied %s times for this " - "salesman and there's an stablished limit of %s." - ) - % (coupons_count, salesman_rule.rule_max_salesman_application) - } + max_rule = salesman_rule.rule_max_salesman_application + times_used = salesman_rule.rule_times_used + if times_used and times_used >= max_rule: + return { + "error": _( + "This promotion was already applied %s times for this " + "salesman and there's an stablished limit of %s." + ) + % (times_used, max_rule) + } if self.program_id.rule_salesmen_strict_limit and not salesman_rule: return {"error": _("This promotion is restricted to the listed salesmen.")} return message diff --git a/sale_loyalty_limit/models/sale_coupon_program.py b/sale_loyalty_limit/models/sale_coupon_program.py index b620fea17..0799c7b55 100644 --- a/sale_loyalty_limit/models/sale_coupon_program.py +++ b/sale_loyalty_limit/models/sale_coupon_program.py @@ -45,27 +45,17 @@ def _check_promo_code(self, order, coupon_code): salesman_rule = self.rule_salesmen_limit_ids.filtered( lambda x: order.user_id == x.rule_user_id ) - if salesman_rule: - salesman_domain = domain + [("user_id", "=", order.user_id.id)] - order_count = self.env["sale.order"].search_count(salesman_domain) - limit_reached = order_count >= salesman_rule.rule_max_salesman_application - if limit_reached and coupon_code: - return { - "error": _( - "This promo code was already applied %s times for this " - "salesman and there's an stablished limit of %s for this " - "promotion." - ) - % (order_count, salesman_rule.rule_max_salesman_application) - } - elif limit_reached and not coupon_code: - return { - "error": _( - "This promotion was already applied %s times for this " - "salesman and there's an stablished limit of %s." - ) - % (order_count, salesman_rule.rule_max_salesman_application) - } + max_rule = salesman_rule.rule_max_salesman_application + times_used = salesman_rule.rule_times_used + if times_used and times_used >= max_rule: + return { + "error": _( + "This promo code was already applied %s times for this " + "salesman and there's an stablished limit of %s for this " + "promotion." + ) + % (times_used, max_rule) + } if self.rule_salesmen_strict_limit and not salesman_rule: return {"error": _("This promotion is restricted to the listed salesmen.")} return message diff --git a/sale_loyalty_limit/models/sale_coupon_rule.py b/sale_loyalty_limit/models/sale_coupon_rule.py index 6c24309e5..db958eb43 100644 --- a/sale_loyalty_limit/models/sale_coupon_rule.py +++ b/sale_loyalty_limit/models/sale_coupon_rule.py @@ -28,14 +28,24 @@ class SaleCouponRule(models.Model): string="Salesmen maximum promotions", compute="_compute_rule_salesmen_limit_count", ) + rule_salesmen_limit_used_count = fields.Integer( + string="Salesmen promotions used", compute="_compute_rule_salesmen_limit_count", + ) - @api.depends("rule_salesmen_limit_ids.rule_max_salesman_application") + @api.depends( + "rule_salesmen_limit_ids.rule_max_salesman_application", + "rule_salesmen_limit_ids.rule_times_used", + ) def _compute_rule_salesmen_limit_count(self): + """This count is merely informative""" + self.rule_salesmen_limit_count = 0 + self.rule_salesmen_limit_used_count = 0 for rule in self: rule.rule_salesmen_limit_count = sum( - rule.rule_salesmen_limit_ids.mapped( - "rule_salesmen_limit_ids.rule_max_salesman_application" - ) + rule.rule_salesmen_limit_ids.mapped("rule_max_salesman_application") + ) + rule.rule_salesmen_limit_used_count = sum( + rule.rule_salesmen_limit_ids.mapped("rule_times_used") ) @@ -57,6 +67,7 @@ class SaleCouponRuleSalesmenLimit(models.Model): default=0, help="Maximum times a salesman can apply a program. 0 for no limit.", ) + rule_times_used = fields.Integer(string="Uses", compute="_compute_rule_times_used",) _sql_constraints = [ ( @@ -65,3 +76,50 @@ class SaleCouponRuleSalesmenLimit(models.Model): "This salesman limit is already configured", ), ] + + @api.depends("rule_user_id", "rule_max_salesman_application") + def _compute_rule_times_used(self): + """This count is also used in the check methods to avoid applying the rule + above the salesmen limits.""" + self.rule_times_used = 0 + programs = self.env["sale.coupon.program"].search_read( + [("rule_id", "in", self.mapped("rule_id").ids)], + ["id", "rule_id", "program_type", "coupon_ids", "rule_salesmen_limit_ids"], + ) + coupon_programs = [ + p + for p in programs + if p["program_type"] + and p["program_type"] == "coupon_program" + or p["coupon_ids"] + ] + for program in programs: + salesmen_limits = self.filtered( + lambda x: x._origin.id in program["rule_salesmen_limit_ids"] + ) + for salesman_limit in salesmen_limits: + if program in coupon_programs and not program["coupon_ids"]: + continue + elif program in coupon_programs: + salesman_limit.rule_times_used = self.env[ + "sale.coupon" + ].search_count( + [ + ("program_id", "=", program["id"]), + ("state", "=", "used"), + ( + "sales_order_id.user_id", + "=", + salesman_limit.rule_user_id.id, + ), + ] + ) + continue + salesman_limit.rule_times_used = self.env["sale.order"].search_count( + [ + "|", + ("no_code_promo_program_ids", "in", [program["id"]]), + ("code_promo_program_id", "=", [program["id"]]), + ("user_id", "=", salesman_limit.rule_user_id.id), + ] + ) diff --git a/sale_loyalty_limit/tests/test_sale_coupon_limit.py b/sale_loyalty_limit/tests/test_sale_coupon_limit.py index 18ad8ea4c..30ed81af1 100644 --- a/sale_loyalty_limit/tests/test_sale_coupon_limit.py +++ b/sale_loyalty_limit/tests/test_sale_coupon_limit.py @@ -272,6 +272,7 @@ def test_07_coupon_code_salesman_limit(self): sale_5 = self._create_sale(self.partner_1, self.salesman_2) self._apply_coupon(sale_5, last_coupon.code) self.assertTrue(bool(sale_5.order_line.filtered("is_reward_line"))) + self.coupon_program.rule_salesmen_limit_ids.mapped("rule_times_used") def test_08_coupon_code_next_order_salesmen_limit(self): """Coupons should not be generated for next orders above the salesman limit""" diff --git a/sale_loyalty_limit/views/sale_coupon_program_views.xml b/sale_loyalty_limit/views/sale_coupon_program_views.xml index cdd6a832e..813362375 100644 --- a/sale_loyalty_limit/views/sale_coupon_program_views.xml +++ b/sale_loyalty_limit/views/sale_coupon_program_views.xml @@ -8,6 +8,7 @@ sale.coupon.program +