diff --git a/addons/coupon/__manifest__.py b/addons/coupon/__manifest__.py deleted file mode 100644 index b4a6897f794..00000000000 --- a/addons/coupon/__manifest__.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. -{ - 'name': "Coupon", - 'summary': "Use discount coupons in different sales channels.", - 'description': """Integrate coupon mechanism in orders.""", - 'category': 'Sales', - 'version': '1.0', - 'depends': ['account'], - 'data': [ - 'wizard/coupon_generate_views.xml', - 'security/ir.model.access.csv', - 'security/coupon_security.xml', - 'views/coupon_views.xml', - 'views/coupon_program_views.xml', - 'report/coupon_report.xml', - 'report/coupon_report_templates.xml', - 'data/expire_coupon_cron.xml', - ], - 'demo': [ - 'demo/coupon_demo.xml', - ], - 'installable': True, - 'license': 'LGPL-3', -} diff --git a/addons/coupon/data/expire_coupon_cron.xml b/addons/coupon/data/expire_coupon_cron.xml deleted file mode 100644 index f72f93eb1ad..00000000000 --- a/addons/coupon/data/expire_coupon_cron.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - Coupon: expire coupon based on date - - code - model.cron_expire_coupon() - - - 1 - days - -1 - - - diff --git a/addons/coupon/demo/coupon_demo.xml b/addons/coupon/demo/coupon_demo.xml deleted file mode 100644 index a31fc68c7be..00000000000 --- a/addons/coupon/demo/coupon_demo.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - 10.0% discount on total amount - service - - - - - 10PERCENTDISC - - - - - Code for 10% on orders - code_needed - 10pc - on_order - percentage - 10.0 - promotion_program - - 0 - - - - Free Product - Large Cabinet - service - - - - - FREELARGECABINET - - - - - Buy 3 large cabinets, get one for free - no_code_needed - on_order - product - promotion_program - - 3 - [["name","ilike","large cabinet"]] - - 0 - - - - 10% Discount - code_needed - on_order - percentage - 10.0 - coupon_program - - - diff --git a/addons/coupon/i18n/coupon.pot b/addons/coupon/i18n/coupon.pot deleted file mode 100644 index ff41ccc0d71..00000000000 --- a/addons/coupon/i18n/coupon.pot +++ /dev/null @@ -1,1096 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * coupon -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server saas~14.5\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-09-14 10:28+0000\n" -"PO-Revision-Date: 2021-09-14 10:28+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: coupon -#: code:addons/coupon/models/coupon_reward.py:0 -#, python-format -msgid "%(amount)s %(currency)s discount on total amount" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_reward.py:0 -#, python-format -msgid "%(percentage)s%% discount on %(product_name)s" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_reward.py:0 -#, python-format -msgid "%s%% discount on cheapest product" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_reward.py:0 -#, python-format -msgid "%s%% discount on products" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_reward.py:0 -#, python-format -msgid "%s%% discount on total amount" -msgstr "" - -#. module: coupon -#: code:addons/coupon/wizard/coupon_generate.py:0 -#, python-format -msgid "%s, a coupon has been generated for you" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "*Valid for following products:" -msgstr "" - -#. module: coupon -#: model:coupon.program,name:coupon.10_percent_coupon -msgid "10% Discount" -msgstr "" - -#. module: coupon -#: model:product.product,name:coupon.product_product_10_percent_discount -#: model:product.template,name:coupon.product_product_10_percent_discount_product_template -msgid "10.0% discount on total amount" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "" -"%" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_coupon_program_form -msgid "" -" Days if 0, infinite use" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid " if 0, no limit" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_promo_program_form -msgid "" -" Days\n" -" if 0, coupon doesn't expire" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_promo_program_form -msgid "" -" Orders\n" -" if 0, infinite use" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "Minimum purchase of" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "Valid for purchase above" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "products" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "" -"get free shipping" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.view_coupon_program_kanban -msgid "Active" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.view_coupon_program_kanban -msgid "Coupons" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__active -msgid "A program is available for the customers when active" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__promo_code -msgid "" -"A promotion code is a code that is associated with a marketing discount. For" -" example, a retailer might tell frequent customers to enter the promotion " -"code 'THX001' to receive a 10%% discount on their whole order." -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__program_type -msgid "" -"A promotional program can be either a limited promotional offer without code (applied automatically)\n" -" or with a code (displayed on a magazine for example) that may generate a discount on the current\n" -" order or create a coupon for a next order.\n" -"\n" -" A coupon program generates coupons with a code that can be used to generate a discount on the current\n" -" order or create a coupon for a next order." -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__active -msgid "Active" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__promo_applicability -msgid "Applicability" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Apply Discount" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_program__promo_applicability__on_current_order -msgid "Apply On Current Order" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_promo_program_form -msgid "Apply on First" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_promo_program_search -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_search -msgid "Archived" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_program__promo_code_usage__no_code_needed -msgid "Automatically Applied" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__promo_code_usage -msgid "" -"Automatically Applied - No code is required, if the program rules are met, the reward is applied (Except the global discount or the free shipping rewards which are not cumulative)\n" -"Use a code - If the program rules are met, a valid code is mandatory for the reward to be applied\n" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__rule_partners_domain -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__rule_partners_domain -msgid "Based on Customers" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__rule_products_domain -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__rule_products_domain -msgid "Based on Products" -msgstr "" - -#. module: coupon -#: model_terms:ir.actions.act_window,help:coupon.coupon_program_action_promo_program -msgid "" -"Build up promotion programs to attract more customers with discounts, free products, free delivery, etc.\n" -" You can share promotion codes or grant the promotions automatically if some conditions are met." -msgstr "" - -#. module: coupon -#: model:coupon.program,name:coupon.3_cabinets_plus_1_free -msgid "Buy 3 large cabinets, get one for free" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_generate_view_form -#: model_terms:ir.ui.view,arch_db:coupon.coupon_view_form -#: model_terms:ir.ui.view,arch_db:coupon.coupon_view_tree -msgid "Cancel" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_coupon__state__cancel -msgid "Cancelled" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__code -msgid "Code" -msgstr "" - -#. module: coupon -#: model:coupon.program,name:coupon.10_percent_auto_applied -msgid "Code for 10% on orders" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__company_id -msgid "Company" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon.py:0 -#, python-format -msgid "Compose Email" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Conditions" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "Congratulations" -msgstr "" - -#. module: coupon -#: model:ir.model,name:coupon.model_coupon_coupon -msgid "Coupon" -msgstr "" - -#. module: coupon -#: model:ir.actions.report,name:coupon.report_coupon_code -msgid "Coupon Code" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__coupon_count -msgid "Coupon Count" -msgstr "" - -#. module: coupon -#: model:ir.model,name:coupon.model_coupon_program -#: model:ir.model.fields.selection,name:coupon.selection__coupon_program__program_type__coupon_program -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Coupon Program" -msgstr "" - -#. module: coupon -#: model:ir.actions.act_window,name:coupon.coupon_program_action_coupon_program -msgid "Coupon Programs" -msgstr "" - -#. module: coupon -#: model:ir.model,name:coupon.model_coupon_reward -msgid "Coupon Reward" -msgstr "" - -#. module: coupon -#: model:ir.model,name:coupon.model_coupon_rule -#: model:ir.model.fields,field_description:coupon.field_coupon_program__rule_id -msgid "Coupon Rule" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_promo_program_form -msgid "Coupon Validity" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__rule_date_to -#: model:ir.model.fields,help:coupon.field_coupon_rule__rule_date_to -msgid "Coupon program end date" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__rule_date_from -#: model:ir.model.fields,help:coupon.field_coupon_rule__rule_date_from -msgid "Coupon program start date" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__sequence -msgid "" -"Coupon program will be applied based on given sequence if multiple programs " -"are defined on same condition(For minimum amount)" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__rule_partners_domain -#: model:ir.model.fields,help:coupon.field_coupon_rule__rule_partners_domain -msgid "Coupon program will work for selected customers only" -msgstr "" - -#. module: coupon -#: model:ir.actions.server,name:coupon.expire_coupon_cron_ir_actions_server -#: model:ir.cron,cron_name:coupon.expire_coupon_cron -#: model:ir.cron,name:coupon.expire_coupon_cron -msgid "Coupon: expire coupon based on date" -msgstr "" - -#. module: coupon -#: model:ir.actions.act_window,name:coupon.coupon_action -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_coupon_program_form -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_promo_program_form -#: model_terms:ir.ui.view,arch_db:coupon.coupon_view_form -#: model_terms:ir.ui.view,arch_db:coupon.coupon_view_tree -msgid "Coupons" -msgstr "" - -#. module: coupon -#: model_terms:ir.actions.act_window,help:coupon.coupon_program_action_coupon_program -msgid "Create a new coupon program" -msgstr "" - -#. module: coupon -#: model_terms:ir.actions.act_window,help:coupon.coupon_program_action_promo_program -msgid "Create a new promotion program" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__create_uid -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__create_uid -#: model:ir.model.fields,field_description:coupon.field_coupon_program__create_uid -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__create_uid -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__create_uid -msgid "Created by" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__create_date -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__create_date -#: model:ir.model.fields,field_description:coupon.field_coupon_program__create_date -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__create_date -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__create_date -msgid "Created on" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__currency_id -msgid "Currency" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__partners_domain -msgid "Customer" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__reward_product_uom_id -#: model:ir.model.fields,help:coupon.field_coupon_reward__reward_product_uom_id -msgid "Default unit of measure used for all stock operations." -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__discount_percentage -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__discount_percentage -#: model:ir.model.fields.selection,name:coupon.selection__coupon_reward__reward_type__discount -msgid "Discount" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__reward_type -#: model:ir.model.fields,help:coupon.field_coupon_reward__reward_type -msgid "" -"Discount - Reward will be provided as discount.\n" -"Free Product - Free product will be provide as reward \n" -"Free Shipping - Free shipping will be provided as reward (Need delivery module)" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__discount_apply_on -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__discount_apply_on -msgid "Discount Apply On" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__discount_max_amount -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__discount_max_amount -msgid "Discount Max Amount" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__discount_type -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__discount_type -msgid "Discount Type" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_reward.py:0 -#, python-format -msgid "Discount percentage should be between 1-100" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__display_name -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__display_name -#: model:ir.model.fields,field_description:coupon.field_coupon_program__display_name -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__display_name -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__display_name -msgid "Display Name" -msgstr "" - -#. module: coupon -#: model:ir.model,name:coupon.model_mail_compose_message -msgid "Email composition wizard" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__rule_date_to -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__rule_date_to -msgid "End Date" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__expiration_date -msgid "Expiration Date" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_coupon__state__expired -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_search -msgid "Expired" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_search -msgid "Expired Programs" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__discount_fixed_amount -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__discount_fixed_amount -#: model:ir.model.fields.selection,name:coupon.selection__coupon_reward__discount_type__fixed_amount -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Fixed Amount" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__partner_id -msgid "For Customer" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__reward_product_id -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__reward_product_id -#: model:ir.model.fields.selection,name:coupon.selection__coupon_reward__reward_type__product -msgid "Free Product" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_reward.py:0 -#, python-format -msgid "Free Product - %s" -msgstr "" - -#. module: coupon -#: model:product.product,name:coupon.product_product_free_large_cabinet -#: model:product.template,name:coupon.product_product_free_large_cabinet_product_template -msgid "Free Product - Large Cabinet" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_generate_view_form -msgid "Generate" -msgstr "" - -#. module: coupon -#: model:ir.model,name:coupon.model_coupon_generate_wizard -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_coupon_program_form -msgid "Generate Coupon" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_generate_view_form -msgid "Generate Coupons" -msgstr "" - -#. module: coupon -#: model_terms:ir.actions.act_window,help:coupon.coupon_program_action_coupon_program -msgid "" -"Generate and share coupon codes with your customers to get discounts or free" -" products." -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__coupon_ids -msgid "Generated Coupons" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__generation_type -msgid "Generation Type" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__has_partner_email -msgid "Has Partner Email" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "Here is your reward from" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__id -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__id -#: model:ir.model.fields,field_description:coupon.field_coupon_program__id -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__id -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__id -msgid "ID" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon.py:0 -#, python-format -msgid "Invalid partner." -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon____last_update -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard____last_update -#: model:ir.model.fields,field_description:coupon.field_coupon_program____last_update -#: model:ir.model.fields,field_description:coupon.field_coupon_reward____last_update -#: model:ir.model.fields,field_description:coupon.field_coupon_rule____last_update -msgid "Last Modified on" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__write_uid -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__write_uid -#: model:ir.model.fields,field_description:coupon.field_coupon_program__write_uid -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__write_uid -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__write_uid -msgid "Last Updated by" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__write_date -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__write_date -#: model:ir.model.fields,field_description:coupon.field_coupon_program__write_date -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__write_date -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__write_date -msgid "Last Updated on" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "Logo" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Max Discount Amount" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__maximum_use_number -msgid "Maximum Use Number" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__discount_max_amount -#: model:ir.model.fields,help:coupon.field_coupon_reward__discount_max_amount -msgid "Maximum amount of discount that can be provided" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__maximum_use_number -msgid "Maximum number of sales orders in which reward can be provided" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Minimum Purchase Of" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__rule_min_quantity -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__rule_min_quantity -msgid "Minimum Quantity" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_rules.py:0 -#, python-format -msgid "Minimum purchased amount should be greater than 0" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_rules.py:0 -#, python-format -msgid "Minimum quantity should be greater than 0" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__rule_minimum_amount -#: model:ir.model.fields,help:coupon.field_coupon_rule__rule_minimum_amount -msgid "Minimum required amount to get the reward" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__rule_min_quantity -#: model:ir.model.fields,help:coupon.field_coupon_rule__rule_min_quantity -msgid "Minimum required product quantity to get the reward" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__name -msgid "Name" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__nbr_coupons -#: model:ir.model.fields.selection,name:coupon.selection__coupon_generate_wizard__generation_type__nbr_coupon -msgid "Number of Coupons" -msgstr "" - -#. module: coupon -#: model:ir.actions.act_window,name:coupon.coupon_generate_action -msgid "Number of Coupons To Generate" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_generate_wizard__generation_type__nbr_customer -msgid "Number of Selected Customers" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_generate_wizard__nbr_coupons -msgid "Number of coupons" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_reward__discount_apply_on__cheapest_product -msgid "On Cheapest Product" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_reward__discount_apply_on__on_order -msgid "On Order" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__discount_apply_on -#: model:ir.model.fields,help:coupon.field_coupon_reward__discount_apply_on -msgid "" -"On Order - Discount on whole order\n" -"Cheapest product - Discount on cheapest product of the order\n" -"Specific products - Discount on selected specific products" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__rule_products_domain -#: model:ir.model.fields,help:coupon.field_coupon_rule__rule_products_domain -msgid "On Purchase of selected product, reward will be given" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_reward__discount_apply_on__specific_products -msgid "On Specific Products" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_coupon__state__reserved -msgid "Pending" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_reward__discount_type__percentage -msgid "Percentage" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__discount_type -#: model:ir.model.fields,help:coupon.field_coupon_reward__discount_type -msgid "" -"Percentage - Entered percentage discount will be provided\n" -"Amount - Entered fixed amount discount will be provided" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_coupon__discount_line_product_id -msgid "Product used in the sales order to apply the discount." -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__discount_line_product_id -#: model:ir.model.fields,help:coupon.field_coupon_reward__discount_line_product_id -msgid "" -"Product used in the sales order to apply the discount. Each coupon program " -"has its own reward product for reporting purpose" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__discount_specific_product_ids -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__discount_specific_product_ids -msgid "Products" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__discount_specific_product_ids -#: model:ir.model.fields,help:coupon.field_coupon_reward__discount_specific_product_ids -msgid "" -"Products that will be discounted if the discount is applied on specific " -"products" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__program_id -msgid "Program" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_coupon_program_form -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_promo_program_form -msgid "Program Name" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__program_type -msgid "Program Type" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__promo_code_usage -msgid "Promo Code Usage" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__promo_code -msgid "Promotion Code" -msgstr "" - -#. module: coupon -#: model:ir.actions.act_window,name:coupon.coupon_program_action_promo_program -msgid "Promotion Programs" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_program__program_type__promotion_program -msgid "Promotional Program" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__reward_product_quantity -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__reward_product_quantity -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Quantity" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__reward_id -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Reward" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__reward_description -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__reward_description -msgid "Reward Description" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__discount_line_product_id -#: model:ir.model.fields,field_description:coupon.field_coupon_program__discount_line_product_id -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__discount_line_product_id -msgid "Reward Line Product" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__reward_product_id -#: model:ir.model.fields,help:coupon.field_coupon_reward__reward_product_id -msgid "Reward Product" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__reward_type -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__reward_type -msgid "Reward Type" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__reward_product_quantity -#: model:ir.model.fields,help:coupon.field_coupon_reward__reward_product_quantity -msgid "Reward product quantity" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Rewards" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__rule_minimum_amount -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__rule_minimum_amount -msgid "Rule Minimum Amount" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__rule_minimum_amount_tax_inclusion -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__rule_minimum_amount_tax_inclusion -msgid "Rule Minimum Amount Tax Inclusion" -msgstr "" - -#. module: coupon -#: model:ir.model,name:coupon.model_report_coupon_report_coupon -msgid "Sales Coupon Report" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Select company" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_promo_program_form -msgid "Select customer" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Select product" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Select products" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Select reward product" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_view_tree -msgid "Send" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_program__promo_applicability__on_next_order -msgid "Send a Coupon" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_view_form -msgid "Send by Email" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_coupon__state__sent -msgid "Sent" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__sequence -msgid "Sequence" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_generate_view_form -msgid "" -"Some selected customers do not have an email address and will not receive " -"the coupon." -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_generate_view_form -msgid "Specify a mail template to send the generated coupons as email." -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__rule_date_from -#: model:ir.model.fields,field_description:coupon.field_coupon_rule__rule_date_from -msgid "Start Date" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_coupon__state -msgid "State" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_rule__rule_minimum_amount_tax_inclusion__tax_excluded -msgid "Tax Excluded" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_rule__rule_minimum_amount_tax_inclusion__tax_included -msgid "Tax Included" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "Thank you," -msgstr "" - -#. module: coupon -#: model:ir.model.constraint,message:coupon.constraint_coupon_coupon_unique_coupon_code -msgid "The coupon code must be unique!" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon.py:0 -#, python-format -msgid "The coupon program for %s is in draft or closed state" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__discount_fixed_amount -#: model:ir.model.fields,help:coupon.field_coupon_reward__discount_fixed_amount -msgid "The discount in fixed amount" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__discount_percentage -#: model:ir.model.fields,help:coupon.field_coupon_reward__discount_percentage -msgid "The discount in percentage, between 1 and 100" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_program.py:0 -#, python-format -msgid "The program code must be unique!" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_rules.py:0 -#, python-format -msgid "The start date must be before the end date" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon.py:0 -#, python-format -msgid "This coupon %s exists but the origin sales order is not validated yet." -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon.py:0 -#, python-format -msgid "This coupon has already been used (%s)." -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon.py:0 -#, python-format -msgid "This coupon has been cancelled (%s)." -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon.py:0 -#, python-format -msgid "This coupon is expired (%s)." -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__total_order_count -msgid "Total Order Count" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__reward_product_uom_id -#: model:ir.model.fields,field_description:coupon.field_coupon_reward__reward_product_uom_id -msgid "Unit of Measure" -msgstr "" - -#. module: coupon -#: model:product.product,uom_name:coupon.product_product_10_percent_discount -#: model:product.product,uom_name:coupon.product_product_free_large_cabinet -#: model:product.template,uom_name:coupon.product_product_10_percent_discount_product_template -#: model:product.template,uom_name:coupon.product_product_free_large_cabinet_product_template -msgid "Units" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_program__promo_code_usage__code_needed -msgid "Use a code" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_generate_wizard__template_id -msgid "Use template" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "Use this promo code before" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_coupon__state__used -msgid "Used" -msgstr "" - -#. module: coupon -#: model:ir.model.fields.selection,name:coupon.selection__coupon_coupon__state__new -msgid "Valid" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_form_common -msgid "Validity" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,field_description:coupon.field_coupon_program__validity_duration -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_coupon_program_form -msgid "Validity Duration" -msgstr "" - -#. module: coupon -#: model:ir.model.fields,help:coupon.field_coupon_program__validity_duration -msgid "Validity duration for a coupon after its generation" -msgstr "" - -#. module: coupon -#: code:addons/coupon/models/coupon_program.py:0 -#, python-format -msgid "You can not delete a program in active state" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.coupon_program_view_promo_program_form -msgid "e.g. 10% Discount" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "off %s" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "off on %s" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "off on some products*" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "off on the cheapest product" -msgstr "" - -#. module: coupon -#: model_terms:ir.ui.view,arch_db:coupon.report_coupon -msgid "on your next order" -msgstr "" diff --git a/addons/coupon/models/__init__.py b/addons/coupon/models/__init__.py deleted file mode 100644 index 14d4dcf92a1..00000000000 --- a/addons/coupon/models/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from . import mail_compose_message -from . import coupon -from . import coupon_reward -from . import coupon_rules -from . import coupon_program diff --git a/addons/coupon/models/coupon.py b/addons/coupon/models/coupon.py deleted file mode 100644 index 4bed33c8a3e..00000000000 --- a/addons/coupon/models/coupon.py +++ /dev/null @@ -1,117 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. -import random -from dateutil.relativedelta import relativedelta - -from odoo import api, fields, models, _ - -from uuid import uuid4 - -class Coupon(models.Model): - _name = 'coupon.coupon' - _description = "Coupon" - _rec_name = 'code' - - @api.model - def _generate_code(self): - """Generate a 20 char long pseudo-random string of digits for barcode - generation. - - A decimal serialisation is longer than a hexadecimal one *but* it - generates a more compact barcode (Code128C rather than Code128A). - - Generate 8 bytes (64 bits) barcodes as 16 bytes barcodes are not - compatible with all scanners. - """ - return str(uuid4())[:22] - - code = fields.Char(default=lambda self: self._generate_code(), required=True, readonly=True) - expiration_date = fields.Date('Expiration Date', compute='_compute_expiration_date') - state = fields.Selection([ - ('reserved', 'Pending'), - ('new', 'Valid'), - ('sent', 'Sent'), - ('used', 'Used'), - ('expired', 'Expired'), - ('cancel', 'Cancelled') - ], required=True, default='new') - partner_id = fields.Many2one('res.partner', "For Customer") - program_id = fields.Many2one('coupon.program', "Program") - discount_line_product_id = fields.Many2one('product.product', related='program_id.discount_line_product_id', readonly=False, - help='Product used in the sales order to apply the discount.') - - _sql_constraints = [ - ('unique_coupon_code', 'unique(code)', 'The coupon code must be unique!'), - ] - - @api.depends('create_date', 'program_id.validity_duration') - def _compute_expiration_date(self): - self.expiration_date = 0 - for coupon in self.filtered(lambda x: x.program_id.validity_duration > 0): - coupon.expiration_date = (coupon.create_date + relativedelta(days=coupon.program_id.validity_duration)).date() - - def _get_default_template(self): - return False - - def action_coupon_send(self): - """ Open a window to compose an email, with the edi invoice template - message loaded by default - """ - self.ensure_one() - default_template = self._get_default_template() - compose_form = self.env.ref('mail.email_compose_message_wizard_form', False) - ctx = dict( - default_model='coupon.coupon', - default_res_id=self.id, - default_use_template=bool(default_template), - default_template_id=default_template and default_template.id, - default_composition_mode='comment', - default_email_layout_xmlid='mail.mail_notification_light', - mark_coupon_as_sent=True, - force_email=True, - ) - return { - 'name': _('Compose Email'), - 'type': 'ir.actions.act_window', - 'view_mode': 'form', - 'res_model': 'mail.compose.message', - 'views': [(compose_form.id, 'form')], - 'view_id': compose_form.id, - 'target': 'new', - 'context': ctx, - } - - def action_coupon_cancel(self): - self.state = 'cancel' - - def cron_expire_coupon(self): - self._cr.execute(""" - SELECT C.id FROM COUPON_COUPON as C - INNER JOIN COUPON_PROGRAM as P ON C.program_id = P.id - WHERE C.STATE in ('reserved', 'new', 'sent') - AND P.validity_duration > 0 - AND C.create_date + interval '1 day' * P.validity_duration < now()""") - - expired_ids = [res[0] for res in self._cr.fetchall()] - self.browse(expired_ids).write({'state': 'expired'}) - - def _check_coupon_code(self, order_date, partner_id, **kwargs): - """ Check the validity of this single coupon. - :param order_date Date: - :param partner_id int | boolean: - """ - self.ensure_one() - message = {} - if self.state == 'used': - message = {'error': _('This coupon has already been used (%s).') % (self.code)} - elif self.state == 'reserved': - message = {'error': _('This coupon %s exists but the origin sales order is not validated yet.') % (self.code)} - elif self.state == 'cancel': - message = {'error': _('This coupon has been cancelled (%s).') % (self.code)} - elif self.state == 'expired' or (self.expiration_date and self.expiration_date < order_date): - message = {'error': _('This coupon is expired (%s).') % (self.code)} - elif not self.program_id.active: - message = {'error': _('The coupon program for %s is in draft or closed state') % (self.code)} - elif self.partner_id and self.partner_id.id != partner_id: - message = {'error': _('Invalid partner.')} - return message diff --git a/addons/coupon/models/coupon_program.py b/addons/coupon/models/coupon_program.py deleted file mode 100644 index e5b55603a44..00000000000 --- a/addons/coupon/models/coupon_program.py +++ /dev/null @@ -1,175 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ -from odoo.exceptions import UserError, ValidationError - -import ast - - -class CouponProgram(models.Model): - _name = 'coupon.program' - _description = "Coupon Program" - _inherits = {'coupon.rule': 'rule_id', 'coupon.reward': 'reward_id'} - # We should apply 'discount' promotion first to avoid offering free product when we should not. - # Eg: If the discount lower the SO total below the required threshold - # Note: This is only revelant when programs have the same sequence (which they have by default) - _order = "sequence, reward_type" - - name = fields.Char(required=True, translate=True) - active = fields.Boolean('Active', default=True, help="A program is available for the customers when active") - rule_id = fields.Many2one('coupon.rule', string="Coupon Rule", ondelete='restrict', required=True) - reward_id = fields.Many2one('coupon.reward', string="Reward", ondelete='restrict', required=True, copy=False) - sequence = fields.Integer(copy=False, - help="Coupon program will be applied based on given sequence if multiple programs are " + - "defined on same condition(For minimum amount)") - maximum_use_number = fields.Integer(help="Maximum number of sales orders in which reward can be provided") - program_type = fields.Selection([ - ('promotion_program', 'Promotional Program'), - ('coupon_program', 'Coupon Program'), - ], - help="""A promotional program can be either a limited promotional offer without code (applied automatically) - or with a code (displayed on a magazine for example) that may generate a discount on the current - order or create a coupon for a next order. - - A coupon program generates coupons with a code that can be used to generate a discount on the current - order or create a coupon for a next order.""") - promo_code_usage = fields.Selection([ - ('no_code_needed', 'Automatically Applied'), - ('code_needed', 'Use a code')], - help="Automatically Applied - No code is required, if the program rules are met, the reward is applied (Except the global discount or the free shipping rewards which are not cumulative)\n" + - "Use a code - If the program rules are met, a valid code is mandatory for the reward to be applied\n") - promo_code = fields.Char('Promotion Code', copy=False, - help="A promotion code is a code that is associated with a marketing discount. For example, a retailer might tell frequent customers to enter the promotion code 'THX001' to receive a 10%% discount on their whole order.") - promo_applicability = fields.Selection([ - ('on_current_order', 'Apply On Current Order'), - ('on_next_order', 'Send a Coupon')], - default='on_current_order', string="Applicability") - coupon_ids = fields.One2many('coupon.coupon', 'program_id', string="Generated Coupons", copy=False) - coupon_count = fields.Integer(compute='_compute_coupon_count') - company_id = fields.Many2one('res.company', string="Company", required=True, default=lambda self: self.env.company) - currency_id = fields.Many2one(string="Currency", related='company_id.currency_id', readonly=True) - validity_duration = fields.Integer(default=30, - help="Validity duration for a coupon after its generation") - total_order_count = fields.Integer("Total Order Count", compute="_compute_total_order_count") - - @api.constrains('promo_code') - def _check_promo_code_constraint(self): - """ Program code must be unique """ - for program in self.filtered(lambda p: p.promo_code): - domain = [('id', '!=', program.id), ('promo_code', '=', program.promo_code)] - if self.search(domain): - raise ValidationError(_('The program code must be unique!')) - - @api.depends('coupon_ids') - def _compute_coupon_count(self): - coupon_data = self.env['coupon.coupon']._read_group([('program_id', 'in', self.ids)], ['program_id'], ['program_id']) - mapped_data = dict([(m['program_id'][0], m['program_id_count']) for m in coupon_data]) - for program in self: - program.coupon_count = mapped_data.get(program.id, 0) - - @api.onchange('promo_code_usage') - def _onchange_promo_code_usage(self): - if self.promo_code_usage == 'no_code_needed': - self.promo_code = False - - @api.onchange('reward_product_id') - def _onchange_reward_product_id(self): - if self.reward_product_id: - self.reward_product_uom_id = self.reward_product_id.uom_id - - @api.onchange('discount_type') - def _onchange_discount_type(self): - if self.discount_type == 'fixed_amount': - self.discount_apply_on = 'on_order' - - @api.model_create_multi - def create(self, vals_list): - programs = super().create(vals_list) - for program in programs: - if program.discount_line_product_id: - continue - values = program._get_discount_product_values() - discount_line_product_id = self.env['product.product'].create(values) - program.write({'discount_line_product_id': discount_line_product_id.id}) - return programs - - def write(self, vals): - res = super(CouponProgram, self).write(vals) - reward_fields = [ - 'reward_type', 'reward_product_id', 'discount_type', 'discount_percentage', - 'discount_apply_on', 'discount_specific_product_ids', 'discount_fixed_amount' - ] - if any(field in reward_fields for field in vals): - self.mapped('discount_line_product_id').write({'name': self[0].reward_id.display_name}) - return res - - @api.ondelete(at_uninstall=False) - def _unlink_except_active(self): - if self.filtered('active'): - raise UserError(_('You can not delete a program in active state')) - - def unlink(self): - # get reference to rule and reward - rule = self.rule_id - reward = self.reward_id - # unlink the program - super(CouponProgram, self).unlink() - # then unlink the rule and reward - rule.unlink() - reward.unlink() - return True - - def toggle_active(self): - super(CouponProgram, self).toggle_active() - for program in self: - program.discount_line_product_id.active = program.active - coupons = self.filtered(lambda p: not p.active and p.promo_code_usage == 'code_needed').mapped('coupon_ids') - coupons.filtered(lambda x: x.state != 'used').write({'state': 'expired'}) - - def _compute_program_amount(self, field, currency_to): - self.ensure_one() - return self.currency_id._convert(self[field], currency_to, self.company_id, fields.Date.today()) - - def _is_valid_partner(self, partner): - if self.rule_partners_domain and self.rule_partners_domain != '[]': - domain = ast.literal_eval(self.rule_partners_domain) + [('id', '=', partner.id)] - return bool(self.env['res.partner'].search_count(domain)) - else: - return True - - def _get_valid_products(self, products): - """Get valid products for the program. - - :param products: records of product.product - :return: valid products recordset - """ - if self.rule_products_domain and self.rule_products_domain != "[]": - domain = ast.literal_eval(self.rule_products_domain) - return products.filtered_domain(domain) - return products - - def _generate_coupons(self, partner_id): - '''Generate coupons that can be used in the next order for the given partner_id.''' - generated_coupons = self.env['coupon.coupon'] - for program in self: - generated_coupons |= self.env['coupon.coupon'].create({ - 'program_id': program.id, - 'partner_id': partner_id, - }) - return generated_coupons - - def _compute_total_order_count(self): - for program in self: - program.total_order_count = 0 - - def _get_discount_product_values(self): - return { - 'name': self.reward_id.display_name, - 'type': 'service', - 'taxes_id': False, - 'supplier_taxes_id': False, - 'sale_ok': False, - 'purchase_ok': False, - 'lst_price': 0, #Do not set a high value to avoid issue with coupon code - } diff --git a/addons/coupon/models/coupon_reward.py b/addons/coupon/models/coupon_reward.py deleted file mode 100644 index d5ad92a9196..00000000000 --- a/addons/coupon/models/coupon_reward.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ -from odoo.exceptions import ValidationError - - -class CouponReward(models.Model): - _name = 'coupon.reward' - _description = "Coupon Reward" - _rec_name = 'reward_description' - - # VFE FIXME multi company - """Rewards are not restricted to a company... - You could have a reward_product_id limited to a specific company A. - But still use this reward as reward of a program of company B... - """ - reward_description = fields.Char('Reward Description') - reward_type = fields.Selection([ - ('discount', 'Discount'), - ('product', 'Free Product'), - ], string='Reward Type', default='discount', - help="Discount - Reward will be provided as discount.\n" + - "Free Product - Free product will be provide as reward \n" + - "Free Shipping - Free shipping will be provided as reward (Need delivery module)") - # Product Reward - reward_product_id = fields.Many2one('product.product', string="Free Product", - help="Reward Product") - reward_product_quantity = fields.Integer(string="Quantity", default=1, help="Reward product quantity") - # Discount Reward - discount_type = fields.Selection([ - ('percentage', 'Percentage'), - ('fixed_amount', 'Fixed Amount')], default="percentage", - help="Percentage - Entered percentage discount will be provided\n" + - "Amount - Entered fixed amount discount will be provided") - discount_percentage = fields.Float(string="Discount", default=10, - help='The discount in percentage, between 1 and 100') - discount_apply_on = fields.Selection([ - ('on_order', 'On Order'), - ('cheapest_product', 'On Cheapest Product'), - ('specific_products', 'On Specific Products')], default="on_order", - help="On Order - Discount on whole order\n" + - "Cheapest product - Discount on cheapest product of the order\n" + - "Specific products - Discount on selected specific products") - discount_specific_product_ids = fields.Many2many('product.product', string="Products", - help="Products that will be discounted if the discount is applied on specific products") - discount_max_amount = fields.Float(default=0, - help="Maximum amount of discount that can be provided") - discount_fixed_amount = fields.Float(string="Fixed Amount", help='The discount in fixed amount') - reward_product_uom_id = fields.Many2one(related='reward_product_id.product_tmpl_id.uom_id', string='Unit of Measure', readonly=True) - discount_line_product_id = fields.Many2one('product.product', string='Reward Line Product', copy=False, - help="Product used in the sales order to apply the discount. Each coupon program has its own reward product for reporting purpose") - - @api.constrains('discount_percentage') - def _check_discount_percentage(self): - if self.filtered(lambda reward: reward.discount_type == 'percentage' and (reward.discount_percentage < 0 or reward.discount_percentage > 100)): - raise ValidationError(_('Discount percentage should be between 1-100')) - - def name_get(self): - """ - Returns a complete description of the reward - """ - result = [] - for reward in self: - reward_string = "" - if reward.reward_type == 'product': - reward_string = _("Free Product - %s", reward.reward_product_id.name) - elif reward.reward_type == 'discount': - if reward.discount_type == 'percentage': - reward_percentage = str(reward.discount_percentage) - if reward.discount_apply_on == 'on_order': - reward_string = _("%s%% discount on total amount", reward_percentage) - elif reward.discount_apply_on == 'specific_products': - if len(reward.discount_specific_product_ids) > 1: - reward_string = _("%s%% discount on products", reward_percentage) - else: - reward_string = _( - "%(percentage)s%% discount on %(product_name)s", - percentage=reward_percentage, - product_name=reward.discount_specific_product_ids.name - ) - elif reward.discount_apply_on == 'cheapest_product': - reward_string = _("%s%% discount on cheapest product", reward_percentage) - elif reward.discount_type == 'fixed_amount': - program = self.env['coupon.program'].search([('reward_id', '=', reward.id)]) - reward_string = _( - "%(amount)s %(currency)s discount on total amount", - amount=reward.discount_fixed_amount, - currency=program.currency_id.name - ) - result.append((reward.id, reward_string)) - return result diff --git a/addons/coupon/models/coupon_rules.py b/addons/coupon/models/coupon_rules.py deleted file mode 100644 index 1412d683db0..00000000000 --- a/addons/coupon/models/coupon_rules.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ -from odoo.exceptions import ValidationError - - -class CouponRule(models.Model): - _name = 'coupon.rule' - _description = "Coupon Rule" - - rule_date_from = fields.Datetime(string="Start Date", help="Coupon program start date") - rule_date_to = fields.Datetime(string="End Date", help="Coupon program end date") - rule_partners_domain = fields.Char(string="Based on Customers", help="Coupon program will work for selected customers only") - rule_products_domain = fields.Char(string="Based on Products", default=[['sale_ok', '=', True]], help="On Purchase of selected product, reward will be given") - rule_min_quantity = fields.Integer(string="Minimum Quantity", default=1, - help="Minimum required product quantity to get the reward") - rule_minimum_amount = fields.Float(default=0.0, help="Minimum required amount to get the reward") - rule_minimum_amount_tax_inclusion = fields.Selection([ - ('tax_included', 'Tax Included'), - ('tax_excluded', 'Tax Excluded')], default="tax_excluded") - - @api.constrains('rule_date_to', 'rule_date_from') - def _check_rule_date_from(self): - if any(applicability for applicability in self - if applicability.rule_date_to and applicability.rule_date_from - and applicability.rule_date_to < applicability.rule_date_from): - raise ValidationError(_('The start date must be before the end date')) - - @api.constrains('rule_minimum_amount') - def _check_rule_minimum_amount(self): - if self.filtered(lambda applicability: applicability.rule_minimum_amount < 0): - raise ValidationError(_('Minimum purchased amount should be greater than 0')) - - @api.constrains('rule_min_quantity') - def _check_rule_min_quantity(self): - if not self.rule_min_quantity > 0: - raise ValidationError(_('Minimum quantity should be greater than 0')) diff --git a/addons/coupon/models/mail_compose_message.py b/addons/coupon/models/mail_compose_message.py deleted file mode 100644 index d4b50a79464..00000000000 --- a/addons/coupon/models/mail_compose_message.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import models - - -class MailComposeMessage(models.TransientModel): - _inherit = 'mail.compose.message' - - def _action_send_mail(self, **kwargs): - for wizard in self: - if self._context.get('mark_coupon_as_sent') and wizard.model == 'coupon.coupon' and wizard.partner_ids: - # Mark coupon as sent in sudo, as helpdesk users don't have the right to write on coupons - self.env[wizard.model].sudo().browse(wizard.res_id).state = 'sent' - return super()._action_send_mail(**kwargs) diff --git a/addons/coupon/report/coupon_report.py b/addons/coupon/report/coupon_report.py deleted file mode 100644 index fd154f6d4d3..00000000000 --- a/addons/coupon/report/coupon_report.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, models - - -class CouponReport(models.AbstractModel): - _name = 'report.coupon.report_coupon' - _description = 'Sales Coupon Report' - - @api.model - def _get_report_values(self, docids, data=None): - docs = self.env['coupon.coupon'].browse(docids) - return { - 'doc_ids': docs.ids, - 'doc_model': 'coupon.coupon', - 'data': data, - 'docs': docs, - } diff --git a/addons/coupon/report/coupon_report.xml b/addons/coupon/report/coupon_report.xml deleted file mode 100644 index 1311db8bbb9..00000000000 --- a/addons/coupon/report/coupon_report.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Coupon Code - coupon.coupon - qweb-pdf - coupon.report_coupon_i18n - coupon.report_coupon_i18n - - report - - - diff --git a/addons/coupon/report/coupon_report_templates.xml b/addons/coupon/report/coupon_report_templates.xml deleted file mode 100644 index 459c6e6dd0c..00000000000 --- a/addons/coupon/report/coupon_report_templates.xml +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - diff --git a/addons/coupon/security/coupon_security.xml b/addons/coupon/security/coupon_security.xml deleted file mode 100644 index 349af6c2b50..00000000000 --- a/addons/coupon/security/coupon_security.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - Generate Sales Coupon Rule - - [('create_uid', '=', user.id)] - - diff --git a/addons/coupon/security/ir.model.access.csv b/addons/coupon/security/ir.model.access.csv deleted file mode 100644 index 34836cfdb7c..00000000000 --- a/addons/coupon/security/ir.model.access.csv +++ /dev/null @@ -1,7 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_coupon_coupon,access_coupon_coupon,model_coupon_coupon,base.group_user,0,0,0,0 -access_coupon_reward,access_coupon_reward,model_coupon_reward,base.group_user,0,0,0,0 -access_coupon_rule,access_coupon_rule,model_coupon_rule,base.group_user,0,0,0,0 -access_coupon_program,access_coupon_program,model_coupon_program,base.group_user,0,0,0,0 -access_coupon_generate_wizard,access_coupon_generate_wizard,model_coupon_generate_wizard,base.group_user,0,0,0,0 - diff --git a/addons/coupon/views/coupon_program_views.xml b/addons/coupon/views/coupon_program_views.xml deleted file mode 100644 index 062ee396a83..00000000000 --- a/addons/coupon/views/coupon_program_views.xml +++ /dev/null @@ -1,286 +0,0 @@ - - - - - - coupon.program.common.form - coupon.program - -
- -
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - coupon.program.form - coupon.program - - primary - - -
-
-
- - - - - - - -
-
- - - coupon.program.tree - coupon.program - - - - - - - - - - - - coupon.program.search - coupon.program - - - - - - - - - - - - coupon.program.kanban - coupon.program - - - - - - -
-
- -
-
-
-
Coupons
-
Active
-
-
- -
-
-
-
-
-
-
-
- - - Coupon Programs - coupon.program - tree,kanban,form - - - [('program_type','=', 'coupon_program')] - { - 'default_program_type': 'coupon_program', - 'promo_code_usage': 'code_needed', - 'search_default_opened': 1 - } - -

- Create a new coupon program -

- Generate and share coupon codes with your customers to get discounts or free products. -

-
-
- - - - - coupon.promotion.program.form - coupon.program - - primary - - - - - - - - - - - - - - - - - - - - - - - - - - - - coupon.promotion.program.tree - coupon.program - - - - - - - - - - - - coupon.promotion.program.search - coupon.program - - - - - - - - - - - Promotion Programs - coupon.program - tree,kanban,form - - - [('program_type', '=', 'promotion_program')] - { - 'default_program_type': 'promotion_program', - 'default_promo_code_usage': 'no_code_needed', - 'default_validity_duration': 0, - 'search_default_opened': 1 - } - -

- Create a new promotion program -

- Build up promotion programs to attract more customers with discounts, free products, free delivery, etc. - You can share promotion codes or grant the promotions automatically if some conditions are met. -

-
-
- diff --git a/addons/coupon/views/coupon_views.xml b/addons/coupon/views/coupon_views.xml deleted file mode 100644 index 8b7cc3968aa..00000000000 --- a/addons/coupon/views/coupon_views.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - coupon.coupon.tree - coupon.coupon - - - - - - - +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + loyalty.program.view.tree + loyalty.program + + + + + + + + + + + + + loyalty.program.view.search + loyalty.program + + + + + + + + + + + Promotions, Gift Card, Loyalty + loyalty.program + tree,form + +
+

No loyalty program found.

+

Create a new one from scratch, or use one of the templates below.

+
+
+
+
diff --git a/addons/loyalty/views/loyalty_reward_views.xml b/addons/loyalty/views/loyalty_reward_views.xml new file mode 100644 index 00000000000..66d4f5eb374 --- /dev/null +++ b/addons/loyalty/views/loyalty_reward_views.xml @@ -0,0 +1,110 @@ + + + + loyalty.reward.view.form + loyalty.reward + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + loyalty.reward.view.kanban + loyalty.reward + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+ + +
+
+ + +
+
+
+

With (or more)

+
+
+
+
+
+
+
diff --git a/addons/loyalty/views/loyalty_rule_views.xml b/addons/loyalty/views/loyalty_rule_views.xml new file mode 100644 index 00000000000..bafc74b4d91 --- /dev/null +++ b/addons/loyalty/views/loyalty_rule_views.xml @@ -0,0 +1,110 @@ + + + + loyalty.rule.view.form + loyalty.rule + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + + loyalty.rule.view.kanban + loyalty.rule + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/addons/sale_coupon/wizard/__init__.py b/addons/loyalty/wizard/__init__.py similarity index 72% rename from addons/sale_coupon/wizard/__init__.py rename to addons/loyalty/wizard/__init__.py index 5de288778c4..18790e0b824 100644 --- a/addons/sale_coupon/wizard/__init__.py +++ b/addons/loyalty/wizard/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import sale_coupon_apply_code +from . import loyalty_generate_wizard diff --git a/addons/loyalty/wizard/loyalty_generate_wizard.py b/addons/loyalty/wizard/loyalty_generate_wizard.py new file mode 100644 index 00000000000..fbb7397c420 --- /dev/null +++ b/addons/loyalty/wizard/loyalty_generate_wizard.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.osv import expression + +class LoyaltyGenerateWizard(models.TransientModel): + _name = 'loyalty.generate.wizard' + _description = 'Generate Coupons' + + program_id = fields.Many2one('loyalty.program', required=True, default=lambda self: self.env.context.get('active_id', False)) + program_type = fields.Selection(related='program_id.program_type') + + mode = fields.Selection([ + ('anonymous', 'Anonymous Customers'), + ('selected', 'Selected Customers')], + string='For', required=True, default='anonymous' + ) + + customer_ids = fields.Many2many('res.partner', string='Customers') + customer_tag_ids = fields.Many2many('res.partner.category', string='Customer Tags') + + coupon_qty = fields.Integer('Quantity', + compute='_compute_coupon_qty', readonly=False, store=True) + points_granted = fields.Float('Grant', required=True, default=1) + points_name = fields.Char(related='program_id.portal_point_name', readonly=True) + valid_until = fields.Date() + + def _get_partners(self): + self.ensure_one() + if self.mode != 'selected': + return self.env['res.partner'] + domain = [] + if self.customer_ids: + domain = [('id', 'in', self.customer_ids.ids)] + if self.customer_tag_ids: + domain = expression.OR([domain, [('category_id', 'in', self.customer_tag_ids.ids)]]) + return self.env['res.partner'].search(domain) + + @api.depends('customer_ids', 'customer_tag_ids', 'mode') + def _compute_coupon_qty(self): + for wizard in self: + if wizard.mode == 'selected': + wizard.coupon_qty = len(wizard._get_partners()) + else: + wizard.coupon_qty = wizard.coupon_qty or 0 + + def _get_coupon_values(self, partner): + self.ensure_one() + return { + 'program_id': self.program_id.id, + 'points': self.points_granted, + 'expiration_date': self.valid_until, + 'partner_id': partner.id if self.mode == 'selected' else False, + } + + def generate_coupons(self): + if any(not wizard.program_id for wizard in self): + raise ValidationError(_("Can not generate coupon, no program is set.")) + if any(wizard.coupon_qty <= 0 for wizard in self): + raise ValidationError(_("Invalid quantity.")) + coupon_create_vals = [] + for wizard in self: + customers = wizard._get_partners() or range(wizard.coupon_qty) + for partner in customers: + coupon_create_vals.append(wizard._get_coupon_values(partner)) + self.env['loyalty.card'].create(coupon_create_vals) + return True diff --git a/addons/loyalty/wizard/loyalty_generate_wizard_views.xml b/addons/loyalty/wizard/loyalty_generate_wizard_views.xml new file mode 100644 index 00000000000..ed4b5f18d89 --- /dev/null +++ b/addons/loyalty/wizard/loyalty_generate_wizard_views.xml @@ -0,0 +1,39 @@ + + + + loyalty.generate.wizard.view.form + loyalty.generate.wizard + +
+ + + + + + + + + + + +
+
+
+
+
+ + + Generate Coupons + loyalty.generate.wizard + form + new + +
diff --git a/addons/gift_card/__init__.py b/addons/loyalty_delivery/__init__.py similarity index 100% rename from addons/gift_card/__init__.py rename to addons/loyalty_delivery/__init__.py diff --git a/addons/loyalty_delivery/__manifest__.py b/addons/loyalty_delivery/__manifest__.py new file mode 100644 index 00000000000..94acac101bc --- /dev/null +++ b/addons/loyalty_delivery/__manifest__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'Coupons & Loyalty - Delivery', + 'summary': "Add a free shipping option to your rewards", + 'category': 'Sales', + 'version': '1.0', + 'depends': ['loyalty', 'delivery'], + 'data': [ + 'data/loyalty_delivery_data.xml', + 'views/loyalty_reward_views.xml', + ], + 'assets': { + }, + 'installable': True, + 'auto_install': True, + 'license': 'LGPL-3', +} diff --git a/addons/loyalty_delivery/data/loyalty_delivery_data.xml b/addons/loyalty_delivery/data/loyalty_delivery_data.xml new file mode 100644 index 00000000000..e2bbacbc133 --- /dev/null +++ b/addons/loyalty_delivery/data/loyalty_delivery_data.xml @@ -0,0 +1,9 @@ + + + + none + + + none + + diff --git a/addons/gift_card/models/__init__.py b/addons/loyalty_delivery/models/__init__.py similarity index 62% rename from addons/gift_card/models/__init__.py rename to addons/loyalty_delivery/models/__init__.py index 798243253f1..29b01a33788 100644 --- a/addons/gift_card/models/__init__.py +++ b/addons/loyalty_delivery/models/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import gift_card -from . import product +from . import loyalty_program +from . import loyalty_reward diff --git a/addons/loyalty_delivery/models/loyalty_program.py b/addons/loyalty_delivery/models/loyalty_program.py new file mode 100644 index 00000000000..98155c27709 --- /dev/null +++ b/addons/loyalty_delivery/models/loyalty_program.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, models + +class LoyaltyProgram(models.Model): + _inherit = 'loyalty.program' + + @api.model + def _program_type_default_values(self): + res = super()._program_type_default_values() + # Add a loyalty reward for free shipping + res['loyalty']['reward_ids'].append((0, 0, { + 'reward_type': 'shipping', + 'required_points': 100, + })) + return res + + @api.model + def get_program_templates(self): + # Override 'promotion' template to say free shipping + res = super().get_program_templates() + res['promotion']['description'] = _("Automatic promotion: free shipping on orders higher than $50") + return res + + @api.model + def _get_template_values(self): + res = super()._get_template_values() + res['promotion']['reward_ids'] = [(5, 0, 0), (0, 0, { + 'reward_type': 'shipping', + })] + return res diff --git a/addons/loyalty_delivery/models/loyalty_reward.py b/addons/loyalty_delivery/models/loyalty_reward.py new file mode 100644 index 00000000000..cbf35ac6b0e --- /dev/null +++ b/addons/loyalty_delivery/models/loyalty_reward.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, fields, models + +class LoyaltyReward(models.Model): + _inherit = 'loyalty.reward' + + reward_type = fields.Selection( + selection_add=[('shipping', 'Free Shipping')], + ondelete={'shipping': 'set default'}) + + def _compute_description(self): + shipping_rewards = self.filtered(lambda r: r.reward_type == 'shipping') + super(LoyaltyReward, self - shipping_rewards)._compute_description() + shipping_rewards.description = _('Free shipping') + for reward in shipping_rewards: + if reward.discount_max_amount: + format_string = '%(amount)g %(symbol)s' + if reward.currency_id.position == 'before': + format_string = '%(symbol)s %(amount)g' + formatted_amount = format_string % {'amount': reward.discount_max_amount, 'symbol': reward.currency_id.symbol} + reward.description += _(' (Max %s)', formatted_amount) diff --git a/addons/loyalty_delivery/views/loyalty_reward_views.xml b/addons/loyalty_delivery/views/loyalty_reward_views.xml new file mode 100644 index 00000000000..559c3257161 --- /dev/null +++ b/addons/loyalty_delivery/views/loyalty_reward_views.xml @@ -0,0 +1,15 @@ + + + + loyalty.reward.view.form.inherit.loyalty.delivery + loyalty.reward + + + + + + + + + + diff --git a/addons/point_of_sale/models/res_config_settings.py b/addons/point_of_sale/models/res_config_settings.py index 038f5fef792..7b41fc7c2d5 100644 --- a/addons/point_of_sale/models/res_config_settings.py +++ b/addons/point_of_sale/models/res_config_settings.py @@ -11,9 +11,7 @@ class ResConfigSettings(models.TransientModel): module_pos_adyen = fields.Boolean(string="Adyen Payment Terminal", help="The transactions are processed by Adyen. Set your Adyen credentials on the related payment method.") module_pos_six = fields.Boolean(string="Six Payment Terminal", help="The transactions are processed by Six. Set the IP address of the terminal on the related payment method.") update_stock_quantities = fields.Selection(related="company_id.point_of_sale_update_stock_quantities", readonly=False) - module_pos_coupon = fields.Boolean("Coupon and Promotion Programs", help="Allow the use of coupon and promotion programs in PoS.") account_default_pos_receivable_account_id = fields.Many2one(string='Default Account Receivable (PoS)', related='company_id.account_default_pos_receivable_account_id', readonly=False) - module_pos_gift_card = fields.Boolean("Gift Cards", help="Allow the use of gift card") def set_values(self): super(ResConfigSettings, self).set_values() diff --git a/addons/point_of_sale/views/res_config_settings_views.xml b/addons/point_of_sale/views/res_config_settings_views.xml index 21257f2aa5e..6e269caa1ef 100644 --- a/addons/point_of_sale/views/res_config_settings_views.xml +++ b/addons/point_of_sale/views/res_config_settings_views.xml @@ -66,28 +66,6 @@ -
-
- -
-
-
-
-
-
- -
-
-
-

Payments

diff --git a/addons/pos_coupon/__manifest__.py b/addons/pos_coupon/__manifest__.py deleted file mode 100644 index e7ad62462e1..00000000000 --- a/addons/pos_coupon/__manifest__.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -{ - "name": "Point of Sale Coupons", - "version": "1.0", - "category": "Sales/Point Of Sale", - "sequence": 6, - "summary": "Use coupons in Point of Sale", - "description": "", - "depends": ["coupon", "point_of_sale"], - "data": [ - "data/mail_template_data.xml", - 'data/default_barcode_patterns.xml', - "security/ir.model.access.csv", - "views/coupon_views.xml", - "views/coupon_program_views.xml", - "views/pos_config_views.xml", - "views/res_config_settings_views.xml", - ], - "demo": [ - "demo/pos_coupon_demo.xml", - ], - "installable": True, - 'assets': { - 'point_of_sale.assets': [ - 'pos_coupon/static/src/css/coupon.css', - 'pos_coupon/static/src/js/coupon.js', - 'pos_coupon/static/src/js/Orderline.js', - 'pos_coupon/static/src/js/PaymentScreen.js', - 'pos_coupon/static/src/js/ProductScreen.js', - 'pos_coupon/static/src/js/OrderWidget.js', - 'pos_coupon/static/src/js/ControlButtons/PromoCodeButton.js', - 'pos_coupon/static/src/js/ControlButtons/ResetProgramsButton.js', - ], - 'web.assets_tests': [ - 'pos_coupon/static/src/js/tours/**/*', - ], - 'web.assets_qweb': [ - 'pos_coupon/static/src/xml/**/*', - ], - }, - 'license': 'LGPL-3', -} diff --git a/addons/pos_coupon/data/mail_template_data.xml b/addons/pos_coupon/data/mail_template_data.xml deleted file mode 100644 index 98cdbcd6f0c..00000000000 --- a/addons/pos_coupon/data/mail_template_data.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - [POS] Coupon: Send by Email - - Your reward coupon from {{ object.program_id.company_id.name }} - {{ object.program_id.company_id.email }} - {{ object.source_pos_order_id.partner_id.id or object.partner_id.id }} - - - - -
- - Congratulations Brandon Freeman,
-
- - Here is your reward from YourCompany.
- - - - $ 10.0
- off on your next order
-
- - - 10.0 % - - -
- - - - on some products* - - - - Chair floor protection - - - -
- off on the cheapest product - -
- -
- off on your next order - -
-
-
-
- - Chair floor protection
- on your next order
-
- - - get free shipping -
- on your next order
-
-
- Use this promo code - - before 2021-06-05 - -

- 13996301932606901095 -

- - - Minimum purchase of 10 products -
-
- - - Valid for purchase above $10.00 -
-
- - - *Valid for following products: Office Chair Black -
-
-
- Thank you, - -
- --
Mitchell Admin
-
-
-
- - Your Coupon Code - {{ object.partner_id.lang }} - -
-
-
diff --git a/addons/pos_coupon/demo/pos_coupon_demo.xml b/addons/pos_coupon/demo/pos_coupon_demo.xml deleted file mode 100644 index 87908dffa36..00000000000 --- a/addons/pos_coupon/demo/pos_coupon_demo.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - 15% on next order - promotion_program - on_next_order - no_code_needed - 100 - on_order - percentage - 15.0 - 0 - - - - True - - - - - - - - - - - - - - - - - - - diff --git a/addons/pos_coupon/i18n/pos_coupon.pot b/addons/pos_coupon/i18n/pos_coupon.pot deleted file mode 100644 index e0b019f3108..00000000000 --- a/addons/pos_coupon/i18n/pos_coupon.pot +++ /dev/null @@ -1,451 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * pos_coupon -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server saas~15.2\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-02-11 14:34+0000\n" -"PO-Revision-Date: 2022-02-11 14:34+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: pos_coupon -#: model:mail.template,body_html:pos_coupon.mail_coupon_template -msgid "" -"\n" -" \n" -" \n" -"
\n" -" \n" -" Congratulations Brandon Freeman,
\n" -"
\n" -"\n" -" Here is your reward from YourCompany.
\n" -"\n" -" \n" -" \n" -" $ 10.0
\n" -" off on your next order
\n" -"
\n" -" \n" -" \n" -" 10.0 %\n" -" \n" -" \n" -"
\n" -" \n" -" \n" -" \n" -" on some products*\n" -" \n" -" \n" -" \n" -" Chair floor protection\n" -" \n" -" \n" -" \n" -"
\n" -" off on the cheapest product\n" -" \n" -"
\n" -" \n" -"
\n" -" off on your next order\n" -" \n" -"
\n" -"
\n" -"
\n" -"
\n" -" \n" -" Chair floor protection
\n" -" on your next order
\n" -"
\n" -" \n" -" \n" -" get free shipping\n" -"
\n" -" on your next order
\n" -"
\n" -"
\n" -" Use this promo code\n" -" \n" -" before 2021-06-05\n" -" \n" -"

\n" -" 13996301932606901095\n" -"

\n" -" \n" -" \n" -" Minimum purchase of 10 products\n" -"
\n" -"
\n" -" \n" -" \n" -" Valid for purchase above $10.00\n" -"
\n" -"
\n" -" \n" -" \n" -" *Valid for following products: Office Chair Black\n" -"
\n" -"
\n" -"
\n" -" Thank you,\n" -" \n" -"
\n" -" --
Mitchell Admin
\n" -"
\n" -"
\n" -" " -msgstr "" - -#. module: pos_coupon -#: code:addons/pos_coupon/models/coupon.py:0 -#, python-format -msgid "" -"A coupon from the same program has already been reserved for this order." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_coupon_program__promo_barcode -msgid "" -"A technical field used as an alternative to the promo_code. This is " -"automatically generated when promo_code is changed." -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/xml/ActivePrograms.xml:0 -#, python-format -msgid "Active Programs" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_pos_order__applied_program_ids -msgid "Applied Programs" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_coupon_coupon__pos_order_id -msgid "Applied on PoS Order" -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/js/ProductScreen.js:0 -#, python-format -msgid "Are you sure you want to deactivate %s in this order?" -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/xml/OrderReceipt.xml:0 -#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__promo_barcode -#, python-format -msgid "Barcode" -msgstr "" - -#. module: pos_coupon -#: model:ir.model,name:pos_coupon.model_barcode_rule -msgid "Barcode Rule" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_pos_order__used_coupon_ids -msgid "Consumed Coupons" -msgstr "" - -#. module: pos_coupon -#: model:ir.model,name:pos_coupon.model_coupon_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_pos_order_line__coupon_id -#: model:ir.model.fields.selection,name:pos_coupon.selection__barcode_rule__type__coupon -msgid "Coupon" -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/xml/OrderReceipt.xml:0 -#, python-format -msgid "Coupon Codes" -msgstr "" - -#. module: pos_coupon -#: model:ir.model,name:pos_coupon.model_coupon_program -msgid "Coupon Program" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_pos_config__coupon_program_ids -#: model:ir.ui.menu,name:pos_coupon.menu_coupon_type_config -#: model_terms:ir.ui.view,arch_db:pos_coupon.res_config_view_form_inherit_pos_coupon -msgid "Coupon Programs" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_pos_order_line__coupon_id -msgid "Coupon that generated this reward." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_pos_config__use_coupon_programs -msgid "Coupons & Promotions" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_pos_config__program_ids -msgid "Coupons and Promotions" -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/js/ProductScreen.js:0 -#, python-format -msgid "Deactivating program" -msgstr "" - -#. module: pos_coupon -#: model_terms:ir.ui.view,arch_db:pos_coupon.pos_coupon_pos_config_view_form -msgid "Define the coupon and promotion programs you can use in this PoS." -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/xml/ControlButtons/PromoCodeButton.xml:0 -#, python-format -msgid "Enter Code" -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/js/ControlButtons/PromoCodeButton.js:0 -#, python-format -msgid "Enter Promotion or Coupon Code" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_pos_order_line__is_program_reward -msgid "" -"Flag indicating that this order line is a result of coupon/promo program." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_pos_order__generated_coupon_ids -msgid "Generated Coupons" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_pos_order_line__is_program_reward -msgid "Is reward line" -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/js/ProductScreen.js:0 -#, python-format -msgid "No" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_coupon_program__pos_order_line_ids -msgid "Order lines where this program is applied." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__pos_order_count -msgid "PoS Order Count" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__pos_order_line_ids -msgid "PoS Order Lines" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_coupon_coupon__source_pos_order_id -msgid "PoS Order Reference" -msgstr "" - -#. module: pos_coupon -#: code:addons/pos_coupon/models/coupon_program.py:0 -#, python-format -msgid "PoS Orders" -msgstr "" - -#. module: pos_coupon -#: model_terms:ir.ui.view,arch_db:pos_coupon.pos_coupon_program_view_coupon_program_form -#: model_terms:ir.ui.view,arch_db:pos_coupon.pos_coupon_program_view_promo_program_form -msgid "PoS Sales" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_coupon_coupon__pos_order_id -msgid "PoS order where this coupon is consumed/booked." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_coupon_coupon__source_pos_order_id -msgid "PoS order where this coupon is generated." -msgstr "" - -#. module: pos_coupon -#: model:ir.model,name:pos_coupon.model_pos_config -msgid "Point of Sale Configuration" -msgstr "" - -#. module: pos_coupon -#: model:ir.model,name:pos_coupon.model_pos_order_line -msgid "Point of Sale Order Lines" -msgstr "" - -#. module: pos_coupon -#: model:ir.model,name:pos_coupon.model_pos_order -msgid "Point of Sale Orders" -msgstr "" - -#. module: pos_coupon -#: model:ir.model,name:pos_coupon.model_pos_session -msgid "Point of Sale Session" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__pos_config_ids -msgid "Point of Sales" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__pos_order_ids -msgid "Pos Order" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_pos_order_line__program_id -msgid "Program" -msgstr "" - -#. module: pos_coupon -#: code:addons/pos_coupon/models/pos_config.py:0 -#, python-format -msgid "Program: %(name)s (%(type)s), Reward Product: `%(reward_product)s`" -msgstr "" - -#. module: pos_coupon -#: model_terms:ir.ui.view,arch_db:pos_coupon.pos_coupon_pos_config_view_form -msgid "Promotion & coupon programs to use." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_pos_config__promo_program_ids -#: model:ir.ui.menu,name:pos_coupon.menu_promotion_type_config -#: model_terms:ir.ui.view,arch_db:pos_coupon.res_config_view_form_inherit_pos_coupon -msgid "Promotion Programs" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_pos_order_line__program_id -msgid "Promotion/Coupon Program where this reward line is based." -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/xml/ControlButtons/ResetProgramsButton.xml:0 -#, python-format -msgid "Reset Programs" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_pos_order__applied_program_ids -msgid "" -"Technical field. This is set when the order is validated. We normally get " -"this value thru the `program_id` of the reward lines." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_coupon_program__pos_order_ids -msgid "The PoS orders where this program is applied." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_coupon_program__valid_partner_ids -msgid "These are the partners that can avail this program." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_coupon_program__valid_product_ids -msgid "These are the products that are valid in this program." -msgstr "" - -#. module: pos_coupon -#: code:addons/pos_coupon/models/pos_config.py:0 -#, python-format -msgid "This coupon is invalid (%s)." -msgstr "" - -#. module: pos_coupon -#: code:addons/pos_coupon/models/pos_config.py:0 -#, python-format -msgid "" -"To continue, make the following reward products to be available in Point of " -"Sale." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_barcode_rule__type -msgid "Type" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,help:pos_coupon.field_pos_config__use_coupon_programs -msgid "Use coupon and promotion programs in this PoS configuration." -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__valid_partner_ids -msgid "Valid Partner" -msgstr "" - -#. module: pos_coupon -#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__valid_product_ids -msgid "Valid Product" -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/xml/OrderReceipt.xml:0 -#, python-format -msgid "Valid until:" -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/js/ProductScreen.js:0 -#, python-format -msgid "Yes" -msgstr "" - -#. module: pos_coupon -#: model:mail.template,report_name:pos_coupon.mail_coupon_template -msgid "Your Coupon Code" -msgstr "" - -#. module: pos_coupon -#: model:mail.template,subject:pos_coupon.mail_coupon_template -msgid "Your reward coupon from {{ object.program_id.company_id.name }} " -msgstr "" - -#. module: pos_coupon -#: model:mail.template,name:pos_coupon.mail_coupon_template -msgid "[POS] Coupon: Send by Email" -msgstr "" - -#. module: pos_coupon -#. openerp-web -#: code:addons/pos_coupon/static/src/xml/OrderReceipt.xml:0 -#, python-format -msgid "no expiration" -msgstr "" diff --git a/addons/pos_coupon/models/__init__.py b/addons/pos_coupon/models/__init__.py deleted file mode 100644 index 883b73daf3f..00000000000 --- a/addons/pos_coupon/models/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from . import pos_config -from . import pos_order -from . import coupon -from . import coupon_program -from . import barcode_rule -from . import pos_session diff --git a/addons/pos_coupon/models/barcode_rule.py b/addons/pos_coupon/models/barcode_rule.py deleted file mode 100644 index d4a3838c8aa..00000000000 --- a/addons/pos_coupon/models/barcode_rule.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import models, fields -from odoo.tools.translate import _ - - -class BarcodeRule(models.Model): - _inherit = 'barcode.rule' - - type = fields.Selection(selection_add=[ - ('coupon', 'Coupon'), - ], ondelete={ - 'coupon': 'set default', - }) diff --git a/addons/pos_coupon/models/coupon.py b/addons/pos_coupon/models/coupon.py deleted file mode 100644 index 3d2ea57d502..00000000000 --- a/addons/pos_coupon/models/coupon.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -# NOTE Use black to automatically format this code. - -from odoo import api, fields, models, _ - - -class Coupon(models.Model): - _inherit = "coupon.coupon" - - source_pos_order_id = fields.Many2one( - "pos.order", - string="PoS Order Reference", - help="PoS order where this coupon is generated.", - ) - pos_order_id = fields.Many2one( - "pos.order", - string="Applied on PoS Order", - help="PoS order where this coupon is consumed/booked.", - ) - - def _check_coupon_code(self, order_date, partner_id, **kwargs): - if self.program_id.id in kwargs.get("reserved_program_ids", []): - return { - "error": _("A coupon from the same program has already been reserved for this order.") - } - return super()._check_coupon_code(order_date, partner_id, **kwargs) - - def _get_default_template(self): - if self.source_pos_order_id: - return self.env.ref('pos_coupon.mail_coupon_template', False) - return super()._get_default_template() - - @api.model - def _generate_code(self): - """ - Modify the generated barcode to be compatible with the default - barcode rule in this module. See `data/default_barcode_patterns.xml`. - """ - code = super()._generate_code() - return '043' + code[3:] diff --git a/addons/pos_coupon/models/coupon_program.py b/addons/pos_coupon/models/coupon_program.py deleted file mode 100644 index 6b53f64cf4d..00000000000 --- a/addons/pos_coupon/models/coupon_program.py +++ /dev/null @@ -1,86 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -# NOTE Use black to automatically format this code. - -from odoo import api, fields, models, _ - -import ast - -class CouponProgram(models.Model): - _inherit = "coupon.program" - - pos_config_ids = fields.Many2many( - "pos.config", - string="Point of Sales", - readonly=True, - ) - pos_order_line_ids = fields.One2many( - "pos.order.line", - "program_id", - string="PoS Order Lines", - help="Order lines where this program is applied.", - ) - promo_barcode = fields.Char( - "Barcode", - default=lambda self: self.env["coupon.coupon"]._generate_code(), - help="A technical field used as an alternative to the promo_code. " - "This is automatically generated when promo_code is changed.", - ) - pos_order_ids = fields.Many2many( - "pos.order", help="The PoS orders where this program is applied.", - ) - pos_order_count = fields.Integer( - "PoS Order Count", compute="_compute_pos_order_count" - ) - valid_product_ids = fields.Many2many( - "product.product", - "Valid Products", - compute="_compute_valid_product_ids", - help="These are the products that are valid in this program.", - ) - valid_partner_ids = fields.Many2many( - "res.partner", - "Valid Partners", - compute="_compute_valid_partner_ids", - help="These are the partners that can avail this program.", - ) - - @api.depends("pos_order_ids") - def _compute_pos_order_count(self): - for program in self: - program.pos_order_count = len(program.pos_order_ids) - - def write(self, vals): - if "promo_code" in vals: - vals.update({"promo_barcode": self.env["coupon.coupon"]._generate_code()}) - return super(CouponProgram, self).write(vals) - - def action_view_pos_orders(self): - self.ensure_one() - return { - "name": _("PoS Orders"), - "view_mode": "tree,form", - "res_model": "pos.order", - "type": "ir.actions.act_window", - "domain": [("id", "in", self.pos_order_ids.ids)], - "context": dict(self._context, create=False), - } - - @api.depends("rule_products_domain") - def _compute_valid_product_ids(self): - for program in self: - domain = ast.literal_eval(program.rule_products_domain) if program.rule_products_domain else [] - program.valid_product_ids = self.env["product.product"].search(domain).ids - - @api.depends("rule_partners_domain") - def _compute_valid_partner_ids(self): - for program in self: - domain = ast.literal_eval(program.rule_partners_domain) if program.rule_partners_domain else [] - program.valid_partner_ids = self.env["res.partner"].search(domain).ids - - @api.depends('pos_order_ids') - def _compute_total_order_count(self): - super(CouponProgram, self)._compute_total_order_count() - for program in self: - program.total_order_count += len(program.pos_order_ids) diff --git a/addons/pos_coupon/models/pos_config.py b/addons/pos_coupon/models/pos_config.py deleted file mode 100644 index 9cbe85c970d..00000000000 --- a/addons/pos_coupon/models/pos_config.py +++ /dev/null @@ -1,104 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -# NOTE Use black to automatically format this code. - -from datetime import datetime - -from odoo import api, fields, models, _ -from odoo.exceptions import UserError - - -class PosConfig(models.Model): - _inherit = "pos.config" - - use_coupon_programs = fields.Boolean( - "Coupons & Promotions", - help="Use coupon and promotion programs in this PoS configuration.", - ) - coupon_program_ids = fields.Many2many( - "coupon.program", - string="Coupon Programs", - compute="_filter_programs", - inverse="_set_programs", - ) - promo_program_ids = fields.Many2many( - "coupon.program", - string="Promotion Programs", - compute="_filter_programs", - inverse="_set_programs", - ) - program_ids = fields.Many2many("coupon.program", string="Coupons and Promotions") - - @api.depends("program_ids") - def _filter_programs(self): - for config in self: - config.coupon_program_ids = config.program_ids.filtered( - lambda program: program.program_type == "coupon_program" - ) - config.promo_program_ids = config.program_ids.filtered( - lambda program: program.program_type == "promotion_program" - ) - - def _set_programs(self): - for config in self: - config.program_ids = config.coupon_program_ids | config.promo_program_ids - - def open_session_cb(self): - # Check validity of programs before opening a new session - invalid_reward_products_msg = "" - for program in self.program_ids: - if ( - program.reward_product_id - and not program.reward_product_id.available_in_pos - ): - reward_product = program.reward_product_id - invalid_reward_products_msg += "\n\t" - invalid_reward_products_msg += _( - "Program: %(name)s (%(type)s), Reward Product: `%(reward_product)s`", - name=program.name, - type=program.program_type, - reward_product=reward_product.name, - ) - - if invalid_reward_products_msg: - intro = _( - "To continue, make the following reward products to be available in Point of Sale." - ) - raise UserError(f"{intro}\n{invalid_reward_products_msg}") - - return super(PosConfig, self).open_session_cb() - - def use_coupon_code(self, code, creation_date, partner_id, reserved_program_ids): - coupon_to_check = self.env["coupon.coupon"].search( - [("code", "=", code), ("program_id", "in", self.program_ids.ids)] - ) - # If not unique, we only check the first coupon. - coupon_to_check = coupon_to_check[:1] - if not coupon_to_check: - return { - "successful": False, - "payload": { - "error_message": _("This coupon is invalid (%s).") % (code) - }, - } - message = coupon_to_check._check_coupon_code( - fields.Date.from_string(creation_date[:11]), - partner_id, - reserved_program_ids=reserved_program_ids, - ) - error_message = message.get("error", False) - if error_message: - return { - "successful": False, - "payload": {"error_message": error_message}, - } - - coupon_to_check.write({"state": "used"}) - return { - "successful": True, - "payload": { - "program_id": coupon_to_check.program_id.id, - "coupon_id": coupon_to_check.id, - }, - } diff --git a/addons/pos_coupon/models/pos_order.py b/addons/pos_coupon/models/pos_order.py deleted file mode 100644 index 07ec797b186..00000000000 --- a/addons/pos_coupon/models/pos_order.py +++ /dev/null @@ -1,88 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -# NOTE Use black to automatically format this code. - -from collections import defaultdict - -from odoo import api, fields, models, _ - - -class PosOrder(models.Model): - _inherit = "pos.order" - - applied_program_ids = fields.Many2many( - "coupon.program", - string="Applied Programs", - help="Technical field. This is set when the order is validated. " - "We normally get this value thru the `program_id` of the reward lines.", - ) - used_coupon_ids = fields.One2many( - "coupon.coupon", "pos_order_id", string="Consumed Coupons" - ) - generated_coupon_ids = fields.One2many( - "coupon.coupon", "source_pos_order_id", string="Generated Coupons" - ) - - def validate_coupon_programs( - self, program_ids_to_generate_coupons, unused_coupon_ids - ): - """This is called after create_from_ui is called. We set here fields - that are used to link programs and coupons to the order. - - We also return the generated coupons that can be used in the frontend - to print the generated codes in the receipt. - """ - self.ensure_one() - - program_ids_to_generate_coupons = program_ids_to_generate_coupons or [] - unused_coupon_ids = unused_coupon_ids or [] - - self.env["coupon.coupon"].browse(unused_coupon_ids).write({"state": "new"}) - self.sudo().write( - { - "applied_program_ids": [(4, i) for i in self.lines.program_id.ids], - "used_coupon_ids": [(4, i) for i in self.lines.coupon_id.ids], - "generated_coupon_ids": [ - (4, i) - for i in ( - self.env["coupon.program"] - .browse(program_ids_to_generate_coupons) - .sudo()._generate_coupons(self.partner_id.id) - ).ids - ], - } - ) - return [ - { - "code": coupon.code, - "expiration_date": coupon.expiration_date, - "program_name": coupon.program_id.name, - } - for coupon in self.generated_coupon_ids - ] - - def _get_fields_for_order_line(self): - fields = super(PosOrder, self)._get_fields_for_order_line() - fields.extend({ - 'is_program_reward', - 'coupon_id', - 'program_id', - }) - return fields - -class PosOrderLine(models.Model): - _inherit = "pos.order.line" - - is_program_reward = fields.Boolean( - "Is reward line", - help="Flag indicating that this order line is a result of coupon/promo program.", - ) - program_id = fields.Many2one( - "coupon.program", - string="Program", - help="Promotion/Coupon Program where this reward line is based.", - ) - coupon_id = fields.Many2one( - "coupon.coupon", string="Coupon", help="Coupon that generated this reward.", - ) diff --git a/addons/pos_coupon/models/pos_session.py b/addons/pos_coupon/models/pos_session.py deleted file mode 100644 index a3fb0f00d57..00000000000 --- a/addons/pos_coupon/models/pos_session.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import models -from odoo.osv.expression import OR - - -class PosSession(models.Model): - _inherit = 'pos.session' - - def _pos_ui_models_to_load(self): - result = super()._pos_ui_models_to_load() - if self.config_id.use_coupon_programs and len(self.config_id.program_ids) > 0: - new_model = 'coupon.program' - if new_model not in result: - result.append(new_model) - return result - - def _loader_params_coupon_program(self): - return { - 'search_params': { - 'domain': [('id', 'in', self.config_id.program_ids.ids), ('active', '=', True)], - 'fields': [] - }, - } - - def _get_pos_ui_coupon_program(self, params): - return self.env['coupon.program'].search_read(**params['search_params']) - - def _loader_params_product_product(self): - result = super(PosSession, self)._loader_params_product_product() - if self.config_id.use_coupon_programs and len(self.config_id.program_ids) > 0: - discount_product_ids = self.config_id.program_ids.mapped(lambda program: program.discount_line_product_id.id) - reward_product_ids = self.config_id.program_ids.mapped(lambda program: program.reward_product_id.id) - product_ids = [id for id in [*discount_product_ids, *reward_product_ids] if id] - result['search_params']['domain'] = OR([result['search_params']['domain'], [('id', 'in', product_ids)]]) - return result diff --git a/addons/pos_coupon/security/ir.model.access.csv b/addons/pos_coupon/security/ir.model.access.csv deleted file mode 100644 index 77029184f56..00000000000 --- a/addons/pos_coupon/security/ir.model.access.csv +++ /dev/null @@ -1,10 +0,0 @@ -id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink -access_program_pos_user,Coupon Program (PoS User),coupon.model_coupon_program,point_of_sale.group_pos_user,1,0,0,0 -access_program_pos_manager,Coupon Program (PoS Manager),coupon.model_coupon_program,point_of_sale.group_pos_manager,1,1,1,1 -access_rule_pos_user,Coupon Rule (PoS User),coupon.model_coupon_rule,point_of_sale.group_pos_user,1,0,0,0 -access_rule_pos_manager,Coupon Rule (PoS Manager),coupon.model_coupon_rule,point_of_sale.group_pos_manager,1,1,1,0 -access_coupon_pos_user,Coupon (PoS User),coupon.model_coupon_coupon,point_of_sale.group_pos_user,1,0,0,0 -access_coupon_pos_manager,Coupon (PoS Manager),coupon.model_coupon_coupon,point_of_sale.group_pos_manager,1,1,1,0 -access_reward_pos_user,Coupon Reward (PoS User),coupon.model_coupon_reward,point_of_sale.group_pos_user,1,0,0,0 -access_reward_pos_manager,Coupon Reward (PoS Manager),coupon.model_coupon_reward,point_of_sale.group_pos_manager,1,1,1,0 -access_coupon_generate_wizard,Coupon Generation,coupon.model_coupon_generate_wizard,point_of_sale.group_pos_user,1,1,1,0 diff --git a/addons/pos_coupon/static/src/css/coupon.css b/addons/pos_coupon/static/src/css/coupon.css deleted file mode 100644 index 2cee4c22964..00000000000 --- a/addons/pos_coupon/static/src/css/coupon.css +++ /dev/null @@ -1,29 +0,0 @@ - -.pos .order .active-programs { - padding-top: 0.5em; - font-weight: normal; - font-size: 80%; - text-align: right; - margin-right: 15px; - margin-left: 15px; -} - -.pos .order .active-programs .title { - padding-top: 0.8em; - padding-bottom: 0.2em; - font-weight: bold; -} - -.pos .order .orderline.program-reward { - font-style: italic; -} - -.pos-receipt .pos-coupon-rewards { - text-align: center; - padding: 1em; -} - -.pos-coupon-rewards .coupon-container { - padding-top: 1em; - font-size: 75%; -} diff --git a/addons/pos_coupon/static/src/js/ControlButtons/PromoCodeButton.js b/addons/pos_coupon/static/src/js/ControlButtons/PromoCodeButton.js deleted file mode 100644 index 07958816209..00000000000 --- a/addons/pos_coupon/static/src/js/ControlButtons/PromoCodeButton.js +++ /dev/null @@ -1,37 +0,0 @@ -odoo.define('pos_coupon.PromoCodeButton', function (require) { - 'use strict'; - - const PosComponent = require('point_of_sale.PosComponent'); - const ProductScreen = require('point_of_sale.ProductScreen'); - const { useListener } = require("@web/core/utils/hooks"); - const Registries = require('point_of_sale.Registries'); - - class PromoCodeButton extends PosComponent { - setup() { - super.setup(); - useListener('click', this.onClick); - } - async onClick() { - const { confirmed, payload: code } = await this.showPopup('TextInputPopup', { - title: this.env._t('Enter Promotion or Coupon Code'), - startingValue: '', - }); - if (confirmed && code !== '') { - const order = this.env.pos.get_order(); - order.activateCode(code); - } - } - } - PromoCodeButton.template = 'PromoCodeButton'; - - ProductScreen.addControlButton({ - component: PromoCodeButton, - condition: function () { - return this.env.pos.config.use_coupon_programs; - }, - }); - - Registries.Component.add(PromoCodeButton); - - return PromoCodeButton; -}); diff --git a/addons/pos_coupon/static/src/js/ControlButtons/ResetProgramsButton.js b/addons/pos_coupon/static/src/js/ControlButtons/ResetProgramsButton.js deleted file mode 100644 index dade62630c0..00000000000 --- a/addons/pos_coupon/static/src/js/ControlButtons/ResetProgramsButton.js +++ /dev/null @@ -1,31 +0,0 @@ -odoo.define('pos_coupon.ResetProgramsButton', function (require) { - 'use strict'; - - const PosComponent = require('point_of_sale.PosComponent'); - const ProductScreen = require('point_of_sale.ProductScreen'); - const { useListener } = require("@web/core/utils/hooks"); - const Registries = require('point_of_sale.Registries'); - - class ResetProgramsButton extends PosComponent { - setup() { - super.setup(); - useListener('click', this.onClick); - } - async onClick() { - const order = this.env.pos.get_order(); - order.resetPrograms(); - } - } - ResetProgramsButton.template = 'ResetProgramsButton'; - - ProductScreen.addControlButton({ - component: ResetProgramsButton, - condition: function () { - return this.env.pos.config.use_coupon_programs; - }, - }); - - Registries.Component.add(ResetProgramsButton); - - return ResetProgramsButton; -}); diff --git a/addons/pos_coupon/static/src/js/OrderWidget.js b/addons/pos_coupon/static/src/js/OrderWidget.js deleted file mode 100644 index 6333fc0bd27..00000000000 --- a/addons/pos_coupon/static/src/js/OrderWidget.js +++ /dev/null @@ -1,65 +0,0 @@ -odoo.define('pos_coupon.OrderWidget', function (require) { - 'use strict'; - - const OrderWidget = require('point_of_sale.OrderWidget'); - const Registries = require('point_of_sale.Registries'); - const { onWillRender } = owl; - - const PosCouponOrderWidget = (OrderWidget) => - class PosCouponOrderWidget extends OrderWidget { - setup() { - super.setup(); - this.withRewardsPromoPrograms = null; - this.withRewardsBookedCoupons = null; - this.onNextOrderPromoPrograms = null; - this.show = null; - onWillRender(() => this.updateActivePrograms()); - } - updateActivePrograms() { - const order = this.env.pos.get_order(); - const unRewardedArray = order.rewardsContainer ? order.rewardsContainer.getUnawarded() : []; - const nonGeneratingProgramIds = new Set(unRewardedArray.map(({ program }) => program.id)); - const nonGeneratingCouponIds = new Set( - unRewardedArray.map(({ coupon_id }) => coupon_id).filter((coupon_id) => coupon_id) - ); - const onNextOrderPromoPrograms = order.activePromoProgramIds - .filter((program_id) => { - const program = order.pos.coupon_programs_by_id[program_id]; - return program.promo_applicability === 'on_next_order' && (order.programIdsToGenerateCoupons || []).includes(program_id); - }) - .map((program_id) => order.pos.coupon_programs_by_id[program_id]); - const onCurrentOrderPromoProgramIds = order.activePromoProgramIds.filter((program_id) => { - const program = order.pos.coupon_programs_by_id[program_id]; - return program.promo_applicability === 'on_current_order'; - }); - const withRewardsPromoPrograms = onCurrentOrderPromoProgramIds - .filter((program_id) => !nonGeneratingProgramIds.has(program_id)) - .map((program_id) => { - const program = order.pos.coupon_programs_by_id[program_id]; - return { - name: program.name, - promo_code: program.promo_code, - }; - }); - const withRewardsBookedCoupons = Object.values(order.bookedCouponCodes) - .filter((couponCode) => !nonGeneratingCouponIds.has(couponCode.coupon_id)) - .map((couponCode) => { - let program = order.pos.coupon_programs_by_id[couponCode.program_id]; - return { - program_name: program.name, - coupon_code: couponCode.code, - }; - }); - this.withRewardsPromoPrograms = withRewardsPromoPrograms; - this.withRewardsBookedCoupons = withRewardsBookedCoupons; - this.onNextOrderPromoPrograms = onNextOrderPromoPrograms; - this.show = withRewardsPromoPrograms.length !== 0 || - withRewardsBookedCoupons.length !== 0 || - onNextOrderPromoPrograms.length !== 0; - } - }; - - Registries.Component.extend(OrderWidget, PosCouponOrderWidget); - - return OrderWidget; -}); diff --git a/addons/pos_coupon/static/src/js/Orderline.js b/addons/pos_coupon/static/src/js/Orderline.js deleted file mode 100644 index fa51bc3f5f0..00000000000 --- a/addons/pos_coupon/static/src/js/Orderline.js +++ /dev/null @@ -1,17 +0,0 @@ -odoo.define('pos_coupon.Orderline', function (require) { - 'use strict'; - - const Orderline = require('point_of_sale.Orderline'); - const Registries = require('point_of_sale.Registries'); - - const PosCouponOrderline = (Orderline) => - class extends Orderline { - get addedClasses() { - return Object.assign({ 'program-reward': this.props.line.is_program_reward }, super.addedClasses); - } - }; - - Registries.Component.extend(Orderline, PosCouponOrderline); - - return Orderline; -}); diff --git a/addons/pos_coupon/static/src/js/PaymentScreen.js b/addons/pos_coupon/static/src/js/PaymentScreen.js deleted file mode 100644 index 1f5879e4c96..00000000000 --- a/addons/pos_coupon/static/src/js/PaymentScreen.js +++ /dev/null @@ -1,39 +0,0 @@ -odoo.define('pos_coupon.PaymentScreen', function (require) { - 'use strict'; - - const PaymentScreen = require('point_of_sale.PaymentScreen'); - const Registries = require('point_of_sale.Registries'); - const session = require('web.session'); - - const PosCouponPaymentScreen = (PaymentScreen) => - class extends PaymentScreen { - async _postPushOrderResolve(order, server_ids) { - const bookedCouponIds = new Set( - Object.values(order.bookedCouponCodes) - .map((couponCode) => couponCode.coupon_id) - .filter((coupon_id) => coupon_id) - ); - const usedCouponIds = order.orderlines - .map((line) => line.coupon_id) - .filter((coupon_id) => coupon_id); - for (let coupon_id of usedCouponIds) { - bookedCouponIds.delete(coupon_id); - } - const unusedCouponIds = [...bookedCouponIds.values()]; - order.generated_coupons = await this.rpc( - { - model: 'pos.order', - method: 'validate_coupon_programs', - args: [server_ids, order.programIdsToGenerateCoupons || [], unusedCouponIds], - kwargs: { context: session.user_context }, - }, - {} - ); - return super._postPushOrderResolve(order, server_ids); - } - }; - - Registries.Component.extend(PaymentScreen, PosCouponPaymentScreen); - - return PaymentScreen; -}); diff --git a/addons/pos_coupon/static/src/js/ProductScreen.js b/addons/pos_coupon/static/src/js/ProductScreen.js deleted file mode 100644 index b51a09dd5d3..00000000000 --- a/addons/pos_coupon/static/src/js/ProductScreen.js +++ /dev/null @@ -1,90 +0,0 @@ -odoo.define('pos_coupon.ProductScreen', function (require) { - 'use strict'; - - const ProductScreen = require('point_of_sale.ProductScreen'); - const Registries = require('point_of_sale.Registries'); - const { useBarcodeReader } = require('point_of_sale.custom_hooks'); - - const PosCouponProductScreen = (ProductScreen) => - class extends ProductScreen { - setup() { - super.setup(); - useBarcodeReader({ - coupon: this._onCouponScan, - }); - } - _onCouponScan(code) { - this.currentOrder.activateCode(code.base_code); - } - async _updateSelectedOrderline(event) { - const selectedLine = this.currentOrder.get_selected_orderline(); - if (selectedLine && selectedLine.is_program_reward && event.detail.key === 'Backspace') { - const program = this.env.pos.coupon_programs_by_id[selectedLine.program_id] - const { confirmed } = await this.showPopup('ConfirmPopup', { - title: this.env._t('Deactivating program'), - body: _.str.sprintf( - this.env._t('Are you sure you want to deactivate %s in this order?'), - program.name - ), - cancelText: this.env._t('No'), - confirmText: this.env._t('Yes'), - }); - if (confirmed) { - event.detail.buffer = null; - } else { - return; // do nothing on the line - } - } - return super._updateSelectedOrderline(...arguments); - } - /** - * 1/ Perform the usual set value operation (super._setValue) if the line being modified - * is not a reward line or if it is a reward line, the `val` being set is '' or 'remove' only. - * - * 2/ Update activated programs and coupons when removing a reward line. - * - * 3/ Trigger 'update-rewards' if the line being modified is a regular line or - * if removing a reward line. - * - * @override - */ - _setValue(val) { - const selectedLine = this.currentOrder.get_selected_orderline(); - if ( - !selectedLine || - !selectedLine.is_program_reward || - (selectedLine.is_program_reward && ['', 'remove'].includes(val)) - ) { - super._setValue(val); - } - if (!selectedLine) return; - if (selectedLine.is_program_reward && val === 'remove') { - if (selectedLine.coupon_id) { - const coupon_code = Object.values(selectedLine.order.bookedCouponCodes).find( - (couponCode) => couponCode.coupon_id === selectedLine.coupon_id - ).code; - delete selectedLine.order.bookedCouponCodes[coupon_code]; - selectedLine.order.resetCoupons([selectedLine.coupon_id]).then(() => { - this.showNotification(`Coupon (${coupon_code}) has been deactivated.`); - }); - } else if (selectedLine.program_id) { - // remove program from active programs - const index = selectedLine.order.activePromoProgramIds.indexOf(selectedLine.program_id); - selectedLine.order.activePromoProgramIds.splice(index, 1); - this.showNotification( - `'${ - this.env.pos.coupon_programs_by_id[selectedLine.program_id].name - }' program has been deactivated.` - ); - } - } - if (!selectedLine.is_program_reward || (selectedLine.is_program_reward && val === 'remove')) { - selectedLine.order._updateRewards(); - } - } - }; - - Registries.Component.extend(ProductScreen, PosCouponProductScreen); - - return ProductScreen; -}); diff --git a/addons/pos_coupon/static/src/js/coupon.js b/addons/pos_coupon/static/src/js/coupon.js deleted file mode 100644 index 0ccebf51487..00000000000 --- a/addons/pos_coupon/static/src/js/coupon.js +++ /dev/null @@ -1,1053 +0,0 @@ -odoo.define('pos_coupon.pos', function (require) { - 'use strict'; - - /** - * When pos_coupon is active (`use_coupon_programs == true`), reward lines - * are generated for each order. Everytime an order is updated ('update-rewards' - * event is triggered), the reward lines are recalculated. Generated reward lines - * are computed based on `bookedCouponCodes` and `activePromoProgramIds` which are - * initialized in `_initializePrograms`. `activePromoProgramIds` start with the - * automatic promo programs - promo programs with `promo_code_usage == 'no_code_needed'`. - * `bookedCouponCodes` and `activePromoProgramIds` containers are then updated when - * scanning codes (see `activateCode`). - * - * In short, `bookedCouponCodes` and `activePromoProgramIds` are populated via - * `activateCode`, then whenever 'update-rewards' is triggered, its callback updates - * the reward lines based on the values stored in `bookedCouponCodes` and - * `activePromoProgramIds` - */ - - const { PosGlobalState, Order, Orderline } = require('point_of_sale.models'); - const rpc = require('web.rpc'); - const session = require('web.session'); - const concurrency = require('web.concurrency'); - const { Gui } = require('point_of_sale.Gui'); - const { float_is_zero } = require('web.utils'); - const Registries = require('point_of_sale.Registries'); - - const dp = new concurrency.DropPrevious(); - - class CouponCode { - /** - * @param {string} code coupon code - * @param {number} coupon_id id of coupon.coupon - * @param {numnber} program_id id of coupon.program - */ - constructor(code, coupon_id, program_id) { - this.code = code; - this.coupon_id = coupon_id; - this.program_id = program_id; - } - } - - class Reward { - static createKey(program_id, coupon_id) { - return coupon_id ? `${program_id}-${coupon_id}` : `${program_id}`; - } - /** - * Generated reward lines are based on the values stored in this object. - * - * @param {product.product} product product used in creating the reward line - * @param {number} unit_price unit price of the reward - * @param {number} quantity - * @param {coupon.program} program - * @param {number[]} tax_ids tax ids - * @param {number?} coupon_id id of the coupon.coupon the generates this reward - * @param {boolean=true} awarded identifies this object as 'awarded' or not. - * @param {string?} reason reason why this reward is 'unawarded'. - */ - constructor({ - product, - unit_price, - quantity, - program, - tax_ids, - coupon_id = undefined, - awarded = true, - reason = undefined, - }) { - this.product = product; - this.unit_price = unit_price; - this.quantity = quantity; - this.program = program; - this.tax_ids = tax_ids; - this.coupon_id = coupon_id; - this._discountAmount = Math.abs(unit_price * quantity); - this.status = { - awarded, - reason, - }; - this._key = Reward.createKey(program.id, coupon_id); - } - /** - * If the program's reward_type is 'product', return the product.product id of the - * reward product. - */ - get rewardedProductId() { - return ( - this.program.reward_type == 'product' && - this.program.reward_product_id && - this.program.reward_product_id[0] - ); - } - get discountAmount() { - return this._discountAmount; - } - get key() { - return this._key; - } - } - - class RewardsContainer { - /** - * The idea here is to have a data structure that will contain the awarded - * and unawarded rewards based on the program and coupon combination. - * If the program-coupon combination does not generate rewards because of - * rules or because it is not the highest global discount, we create - * an 'unawarded' `Reward` that corresponds to it, then we `add` it to - * this data structure. Otherwise, we create an 'awarded' `Reward` object - * then `add` it as well. - * - * We can then get the 'awarded' and 'unawarded' rewards via `getAwarded` - * and `getUnawarded` methods, respectively. - */ - constructor() { - /** - * @type {Record} key is based on `program_id` and `coupon_id`. - */ - this._rewards = {}; - } - clear() { - this._rewards = {}; - } - /** - * @param {Reward[]} rewards - */ - add(rewards) { - for (const reward of rewards) { - if (reward.key in this._rewards) { - this._rewards[reward.key].push(reward); - } else { - this._rewards[reward.key] = [reward]; - } - } - } - getUnawarded() { - return this._getFlattenRewards().filter((reward) => !reward.status.awarded); - } - getAwarded() { - return this._getFlattenRewards().filter((reward) => reward.status.awarded); - } - _getFlattenRewards() { - return Object.values(this._rewards).reduce((flatArr, arr) => [...flatArr, ...arr], []); - } - } - - // Some utility functions - - /** - * Calculate the number of free items based on the given number - * of items `number_items` and the rule: buy `n` take `m`. - * - * e.g. - *``` - * rule: buy 2 take 1 rule: buy 2 take 3 - * +------------+--------+--------+ +------------+--------+--------+ - * |number_items| charged| free| |number_items| charged| free| - * +------------+--------+--------+ +------------+--------+--------+ - * | 1| 1| 0| | 1| 1| 0| - * | 2| 2| 0| | 2| 2| 0| - * | 3| 2| 1| | 3| 2| 1| - * | 4| 3| 1| | 4| 2| 2| - * | 5| 4| 1| | 5| 2| 3| - * | 6| 4| 2| | 6| 3| 3| - * | 7| 5| 2| | 7| 4| 3| - * | 8| 6| 2| | 8| 4| 4| - * | 9| 6| 3| | 9| 4| 5| - * | 10| 7| 3| | 10| 4| 6| - * +------------+--------+--------+ +------------+--------+--------+ - * ``` - * - * @param {number} numberItems number of items - * @param {number} n items to buy - * @param {number} m item for free - * @returns {number} number of free items - */ - function computeFreeQuantity(numberItems, n, m) { - let factor = Math.trunc(numberItems / (n + m)); - let free = factor * m; - let charged = numberItems - free; - // adjust the calculated free quantities - let x = (factor + 1) * n; - let y = x + (factor + 1) * m; - let adjustment = x <= charged && charged < y ? charged - x : 0; - return free + adjustment; - } - - const PosCouponPosGlobalState = (PosGlobalState) => class PosCouponPosGlobalState extends PosGlobalState { - async _processData(loadedData) { - await super._processData(...arguments); - this.programs = loadedData['coupon.program'] || []; - this._loadCouponProgram(); - } - _loadCouponProgram() { - // done in the front end because those are derived value (with the sets) - this.coupon_programs_by_id = {}; - this.coupon_programs = []; - this.promo_programs = []; - for (let program of this.programs) { - this.coupon_programs_by_id[program.id] = program; - // separate coupon programs from promo programs - if (program.program_type === 'coupon_program') { - this.coupon_programs.push(program); - } else { - this.promo_programs.push(program); - } - // cast some arrays to Set for faster membership checking - program.valid_product_ids = new Set(program.valid_product_ids); - program.valid_partner_ids = new Set(program.valid_partner_ids); - program.discount_specific_product_ids = new Set(program.discount_specific_product_ids); - } - } - async load_server_data() { - await super.load_server_data(...arguments); - if (this.selectedOrder) { - this.selectedOrder._updateRewards(); - } - } - set_order(order) { - const result = super.set_order(...arguments); - // FIXME - JCB: This is a temporary fix. - // When an order is selected, it doesn't always contain the reward lines. - // And the list of active programs are not always correct. This is because - // of the use of DropPrevious in _updateRewards. - if (order) { - order._updateRewards(); - } - return result; - } - } - Registries.Model.extend(PosGlobalState, PosCouponPosGlobalState); - - - const PosCouponOrder = (Order) => class PosCouponOrder extends Order { - // OVERIDDEN METHODS - - constructor() { - super(...arguments); - this._initializePrograms(); - } - init_from_JSON(json) { - this.bookedCouponCodes = this.bookedCouponCodes ? this.order.bookedCouponCodes : {}; - this.activePromoProgramIds = this.activePromoProgramIds ? this.order.activePromoProgramIds : []; - super.init_from_JSON(...arguments); - } - export_as_JSON() { - let json = super.export_as_JSON(...arguments); - json.bookedCouponCodes = this.bookedCouponCodes && Object.fromEntries( - [...Object.keys(this.bookedCouponCodes)].map((key) => [key, Object.assign({}, this.bookedCouponCodes[key])]) - ); - json.activePromoProgramIds = this.activePromoProgramIds && [...this.activePromoProgramIds]; - return json; - } - set_orderline_options(orderline, options) { - super.set_orderline_options(...arguments); - if (options && options.is_program_reward) { - orderline.is_program_reward = true; - orderline.program_id = options.program_id; - orderline.coupon_id = options.coupon_id; - } - } - /** - * This function's behavior is modified so that the reward lines are - * rendered at the bottom of the orderlines list. - */ - get_orderlines() { - const orderlines = super.get_orderlines(...arguments); - const rewardLines = []; - const nonRewardLines = []; - for (const line of orderlines) { - if (line.is_program_reward) { - rewardLines.push(line); - } else { - nonRewardLines.push(line); - } - } - return [...nonRewardLines, ...rewardLines]; - } - _getRegularOrderlines() { - const orderlines = this.get_orderlines(); - return orderlines.filter((line) => !line.is_program_reward && !line.refunded_orderline_id); - } - _getRewardLines() { - const orderlines = this.get_orderlines(); - return orderlines.filter((line) => line.is_program_reward); - } - wait_for_push_order() { - return ( - (this.programIdsToGenerateCoupons && this.programIdsToGenerateCoupons.length) || - this.get_orderlines().filter((line) => line.is_program_reward).length || - super.wait_for_push_order(...arguments) - ); - } - export_for_printing() { - let result = super.export_for_printing(...arguments); - result.generated_coupons = this.generated_coupons; - return result; - } - add_product(product, options) { - super.add_product(...arguments); - this._updateRewards(); - } - get_last_orderline() { - const regularLines = this.get_orderlines(...arguments) - .filter((line) => !line.is_program_reward); - return regularLines[regularLines.length - 1]; - } - - // NEW METHODS - - _updateRewards() { - if (!this.pos.config.use_coupon_programs) return; - dp.add(this._getNewRewardLines()).then(([newRewardLines, rewardsContainer]) => { - newRewardLines.forEach((rewardLine) => this.orderlines.add(rewardLine)); - // We need this for the rendering of ActivePrograms component. - this.rewardsContainer = rewardsContainer; - }).catch(() => { /* catch the reject of dp when calling `add` to avoid unhandledrejection */ }); - } - _initializePrograms () { - if (!this.bookedCouponCodes) { - /** - * This field contains the activated coupons. - * @type {Record} key is the coupon code. - */ - this.bookedCouponCodes = {}; - } - if (!this.activePromoProgramIds) { - /** - * This field contains the ids of automatically/manually activated - * promo programs. - * @type {number[]} array of program ids. - */ - this.activePromoProgramIds = this._getAutomaticPromoProgramIds(); - } - } - resetPrograms() { - let deactivatedCount = 0; - if (this.bookedCouponCodes) { - const couponIds = Object.values(this.bookedCouponCodes).map((couponCode) => couponCode.coupon_id); - if (couponIds.length > 0) { - this.resetCoupons(couponIds); - } - this.bookedCouponCodes = {}; - deactivatedCount += couponIds.length; - } - if (this.activePromoProgramIds) { - const codeNeededPromoProgramIds = this.activePromoProgramIds.filter((program_id) => { - return this.pos.coupon_programs_by_id[program_id].promo_code_usage === 'code_needed'; - }); - this.activePromoProgramIds = this._getAutomaticPromoProgramIds(); - deactivatedCount += codeNeededPromoProgramIds.length; - } - if (deactivatedCount > 0) Gui.showNotification('Active coupons and promo codes were deactivated.'); - this._updateRewards(); - } - /** - * Updates `bookedCouponCodes` or `activePromoProgramIds` depending on which code - * is scanned. - * - * @param {string} code - */ - async activateCode(code) { - const promoProgram = this.pos.promo_programs.find( - (program) => program.promo_barcode == code || program.promo_code == code - ); - if (promoProgram && this.activePromoProgramIds.includes(promoProgram.id)) { - Gui.showNotification('That promo code program has already been activated.'); - } else if (promoProgram) { - // TODO these two operations should be atomic - this.activePromoProgramIds.push(promoProgram.id); - this._updateRewards(); - } else if (code in this.bookedCouponCodes) { - Gui.showNotification('That coupon code has already been scanned and activated.'); - } else { - const programIdsWithScannedCoupon = Object.values(this.bookedCouponCodes).map( - (couponCode) => couponCode.program_id - ); - const partner = this.get_partner(); - const { successful, payload } = await rpc.query({ - model: 'pos.config', - method: 'use_coupon_code', - args: [ - [this.pos.config.id], - code, - this.creation_date, - partner ? partner.id : false, - programIdsWithScannedCoupon, - ], - kwargs: { context: session.user_context }, - }); - if (successful) { - // TODO these two operations should be atomic - this.bookedCouponCodes[code] = new CouponCode(code, payload.coupon_id, payload.program_id); - this._updateRewards(); - } else { - Gui.showNotification(payload.error_message); - } - } - } - /** - * @returns {[models.Orderline[], RewardsContainer]} - */ - async _getNewRewardLines() { - // Remove the reward lines before recalculation of rewards. - this._getRewardLines().forEach(rewardLine => this.orderlines.remove(rewardLine)); - const rewardsContainer = await this._calculateRewards(); - // We set the programs that will generate coupons after validation of this order. - // See `_postPushOrderResolve` in the `PaymentScreen`. - await this._setProgramIdsToGenerateCoupons(rewardsContainer); - // Create reward orderlines here based on the content of `rewardsContainer` field. - return [this._getLinesToAdd(rewardsContainer), rewardsContainer]; - } - /** - * @param {number[]} couponIds ids of the coupon.coupon records to reset - */ - async resetCoupons(couponIds) { - await rpc.query( - { - model: 'coupon.coupon', - method: 'write', - args: [couponIds, { state: 'new' }], - kwargs: { context: session.user_context }, - }, - {} - ); - } - /** - * Create orderline rewards based on the `awarded` rewards from `rewardsContainer`. - * - * @param {RewardsContainer} rewardsContainer - * @returns {models.Orderline[]} - */ - _getLinesToAdd(rewardsContainer) { - this.assert_editable(); - return rewardsContainer - .getAwarded() - .map(({ product, unit_price, quantity, program, tax_ids, coupon_id }) => { - const options = { - quantity: quantity, - price: unit_price, - lst_price: unit_price, - is_program_reward: true, - program_id: program.id, - tax_ids: tax_ids, - coupon_id: coupon_id, - }; - const line = Orderline.create({}, { pos: this.pos, order: this, product }); - this.fix_tax_included_price(line); - this.set_orderline_options(line, options); - return line; - }); - } - /** - * Sets the programs ids that will generate coupons based on the `rewardsContainer`. - * If a program do not pass the rules-check, we add an 'unawarded' `Reward` in the - * `rewardsContainer`. - * - * @param {RewardsContainer} rewardsContainer - */ - async _setProgramIdsToGenerateCoupons(rewardsContainer) { - const programIdsToGenerateCoupons = []; - for (let [program] of this._getActiveOnNextPromoPrograms()) { - const { successful, reason } = await this._checkProgramRules(program); - if (successful) { - programIdsToGenerateCoupons.push(program.id); - } else { - const notAwarded = new Reward({ program, reason, awarded: false }); - rewardsContainer.add([notAwarded]); - } - } - this.programIdsToGenerateCoupons = programIdsToGenerateCoupons; - } - /** - * In this method, we compute all the rewards as a result of `bookedCouponCodes` - * and `activePromoProgramIds`. If a program do not generate a reward, we create - * a `Reward` object with `awarded = false` then we `add` this object to the - * `rewardsContainer`. Else, we create a `Reward` object with `awarded = true` then - * we also `add` this to `rewardsContainer`. (See `collectRewards`.) - * - * The procedure in calculating the rewards is as follows: - * - Compute the free product rewards. We will need its result in the calculation - * of discount rewards. - * - Compute the fixed amount discount. This is independent of the free product - * rewards. - * - Compute discount on specific products. Requires the free product rewards. - * - Compute discount on cheapest product. Does not need the free product. We - * only discount the first item of the cheapest product. - * - Compute discount on the whole order. This requires the free product rewards. - * - We consider results of on order and cheapest product discounts as global - * discounts. We only choose one global discount, whichever gives the highest discount. - * - We add the free product rewards, fixed amount discount, discount on specific - * products and the sole global discount to `rewardsContainer` then return it. - * - * @returns {RewardsContainer} - */ - async _calculateRewards() { - const rewardsContainer = new RewardsContainer(); - - if (this._getRegularOrderlines().length === 0) { - return rewardsContainer; - } - - const { - freeProductPrograms, - fixedAmountDiscountPrograms, - onSpecificPrograms, - onCheapestPrograms, - onOrderPrograms, - } = await this._getValidActivePrograms(rewardsContainer); - - const collectRewards = (validPrograms, rewardGetter) => { - const allRewards = []; - for (let [program, coupon_id] of validPrograms) { - const [rewards, reason] = rewardGetter(program, coupon_id); - if (reason) { - const notAwarded = new Reward({ awarded: false, reason, program, coupon_id }); - rewardsContainer.add([notAwarded]); - } - allRewards.push(...rewards); - } - return allRewards; - }; - - // - Gather the product rewards - const freeProducts = collectRewards(freeProductPrograms, this._getProductRewards.bind(this)); - - // - Gather the fixed amount discounts - const fixedAmountDiscounts = collectRewards(fixedAmountDiscountPrograms, this._getFixedDiscount.bind(this)); - - // - Gather the specific discounts - const specificDiscountGetter = (program, coupon_id) => { - return this._getSpecificDiscount(program, coupon_id, freeProducts); - }; - const specificDiscounts = collectRewards(onSpecificPrograms, specificDiscountGetter); - - // - Collect the discounts from on order and on cheapest discount programs. - const globalDiscounts = []; - const onOrderDiscountGetter = (program, coupon_id) => { - return this._getOnOrderDiscountRewards(program, coupon_id, freeProducts); - }; - globalDiscounts.push(...collectRewards(onOrderPrograms, onOrderDiscountGetter)); - globalDiscounts.push(...collectRewards(onCheapestPrograms, this._getOnCheapestProductDiscount.bind(this))); - - // - Group the discounts by program id. - const groupedGlobalDiscounts = {}; - for (let discount of globalDiscounts) { - const key = [discount.program.id, discount.coupon_id].join(','); - if (!(key in groupedGlobalDiscounts)) { - groupedGlobalDiscounts[key] = [discount]; - } else { - groupedGlobalDiscounts[key].push(discount); - } - } - - // - We select the group of discounts with highest total amount. - // Note that the result is an Array that might contain more than one - // discount lines. This is because discounts are grouped by tax. - let currentMaxTotal = 0; - let currentMaxKey = null; - for (let key in groupedGlobalDiscounts) { - const discountRewards = groupedGlobalDiscounts[key]; - const newTotal = discountRewards.reduce((sum, discReward) => sum + discReward.discountAmount, 0); - if (newTotal > currentMaxTotal) { - currentMaxTotal = newTotal; - currentMaxKey = key; - } - } - const theOnlyGlobalDiscount = currentMaxKey - ? groupedGlobalDiscounts[currentMaxKey].filter((discountReward) => discountReward.discountAmount !== 0) - : []; - - // - Get the messages for the discarded global_discounts - if (theOnlyGlobalDiscount.length > 0) { - const theOnlyGlobalDiscountKey = [ - theOnlyGlobalDiscount[0].program.id, - theOnlyGlobalDiscount[0].coupon_id, - ].join(','); - for (let [key, discounts] of Object.entries(groupedGlobalDiscounts)) { - if (key !== theOnlyGlobalDiscountKey) { - const notAwarded = new Reward({ - program: discounts[0].program, - coupon_id: discounts[0].coupon_id, - reason: 'Not the greatest global discount.', - awarded: false, - }); - rewardsContainer.add([notAwarded]); - } - } - } - - // - Add the calculated rewards. - rewardsContainer.add([ - ...freeProducts, - ...fixedAmountDiscounts, - ...specificDiscounts, - ...theOnlyGlobalDiscount, - ]); - - return rewardsContainer; - } - /** - * This method returns the segregated programs based on the types of rewards: - * reward_type === 'product' - * 1. freeProductPrograms - * reward_type === 'discount' - * discount_type === 'fixed_amount' - * 2. fixedAmountDiscountPrograms - * discount_type === 'percentage' - * discount_apply_on === 'specific_products' - * 3. onSpecificPrograms - * discount_apply_on === 'cheapest_product' - * 4. onCheapestPrograms - * discount_apply_on === 'on_order' - * 5. onOrderPrograms - * - * It only includes the valid programs, those that passes the program rules. - * This has side-effect of `add`ing unawarded rewards to the given rewardsContainer. - */ - async _getValidActivePrograms(rewardsContainer) { - const freeProductPrograms = [], - fixedAmountDiscountPrograms = [], - onSpecificPrograms = [], - onCheapestPrograms = [], - onOrderPrograms = []; - - function updateProgramLists(program, coupon_id) { - if (program.reward_type === 'product') { - freeProductPrograms.push([program, coupon_id]); - } else { - if (program.discount_type === 'fixed_amount') { - fixedAmountDiscountPrograms.push([program, coupon_id]); - } else if (program.discount_apply_on === 'specific_products') { - onSpecificPrograms.push([program, coupon_id]); - } else if (program.discount_apply_on === 'cheapest_product') { - onCheapestPrograms.push([program, coupon_id]); - } else { - onOrderPrograms.push([program, coupon_id]); - } - } - } - - for (let [program, coupon_id] of this._getBookedPromoPrograms()) { - // Booked coupons from on next order promo programs do not need - // checking of rules because checks are done before generating - // coupons. - updateProgramLists(program, coupon_id); - } - - for (let [program, coupon_id] of [ - ...this._getBookedCouponPrograms(), - ...this._getActiveOnCurrentPromoPrograms(), - ]) { - const { successful, reason } = await this._checkProgramRules(program); - if (successful) { - updateProgramLists(program, coupon_id); - } else { - // side-effect - const notAwarded = new Reward({ program, coupon_id, reason, awarded: false }); - rewardsContainer.add([notAwarded]); - } - } - - return { - freeProductPrograms, - fixedAmountDiscountPrograms, - onSpecificPrograms, - onCheapestPrograms, - onOrderPrograms, - }; - } - _getAutomaticPromoProgramIds() { - return this.pos.promo_programs - .filter((program) => { - return program.promo_code_usage == 'no_code_needed'; - }) - .map((program) => program.id); - } - /** - * These are the coupon programs that are activated - * via coupon codes. RewardsContainer can only be generated if the coupon - * program rules are satisfied. - * - * @returns {[coupon.program, number][]} - */ - _getBookedCouponPrograms() { - return Object.values(this.bookedCouponCodes) - .map((couponCode) => [ - this.pos.coupon_programs_by_id[couponCode.program_id], - parseInt(couponCode.coupon_id, 10), - ]) - .filter(([program]) => { - return program.program_type === 'coupon_program'; - }); - } - /** - * These are the on_next_order promo programs that are activated - * via coupon codes. RewardsContainer can be generated from this program - * without checking the constraints. - * - * @returns {[coupon.program, number][]} - */ - _getBookedPromoPrograms() { - return Object.values(this.bookedCouponCodes) - .map((couponCode) => [ - this.pos.coupon_programs_by_id[couponCode.program_id], - parseInt(couponCode.coupon_id, 10), - ]) - .filter(([program]) => { - return program.program_type === 'promotion_program'; - }); - } - /** - * These are the active on_current_order promo programs that will generate - * rewards if the program constraints are fully-satisfied. - * - * @returns {[coupon.program, null][]} - */ - _getActiveOnCurrentPromoPrograms() { - return this.activePromoProgramIds - .map((program_id) => [this.pos.coupon_programs_by_id[program_id], null]) - .filter(([program]) => { - return program.promo_applicability === 'on_current_order'; - }); - } - /** - * These are the active on_next_order promo programs that will generate - * coupon codes if the program constraints are fully-satisfied. - * - * @returns {[coupon.program, null][]} - */ - _getActiveOnNextPromoPrograms() { - return this.activePromoProgramIds - .map((program_id) => [this.pos.coupon_programs_by_id[program_id], null]) - .filter(([program]) => { - return program.promo_applicability === 'on_next_order'; - }); - } - /** - * @param {coupon.program} program - * @returns {{ successful: boolean, reason: string | undefined }} - */ - async _checkProgramRules(program) { - // Check minimum amount - const amountToCheck = - program.rule_minimum_amount_tax_inclusion === 'tax_included' - ? this.get_total_with_tax() - : this.get_total_without_tax(); - // TODO jcb rule_minimum_amount has to be converted. - if ( - !( - amountToCheck > program.rule_minimum_amount || - float_is_zero(amountToCheck - program.rule_minimum_amount, this.pos.currency.decimal_places) - ) - ) { - return { - successful: false, - reason: 'Minimum amount for this program is not satisfied.', - }; - } - - // Check minimum quantity - const validQuantity = this._getRegularOrderlines() - .filter((line) => { - return program.valid_product_ids.has(line.product.id); - }) - .reduce((total, line) => total + line.quantity, 0); - if (!(validQuantity >= program.rule_min_quantity)) { - return { - successful: false, - reason: "Program's minimum quantity is not satisfied.", - }; - } - - // Bypass other rules if program is coupon_program - if (program.program_type === 'coupon_program') { - return { - successful: true, - }; - } - - // Check if valid partner - const partner = this.get_partner(); - if (program.rule_partners_domain && !program.valid_partner_ids.has(partner ? partner.id : 0)) { - return { - successful: false, - reason: "Current customer can't avail this program.", - }; - } - - // Check rule date - const ruleFrom = program.rule_date_from ? new Date(program.rule_date_from) : new Date(-8640000000000000); - const ruleTo = program.rule_date_to ? new Date(program.rule_date_to) : new Date(8640000000000000); - const orderDate = new Date(); - if (!(orderDate >= ruleFrom && orderDate <= ruleTo)) { - return { - successful: false, - reason: 'Program already expired.', - }; - } - - // Check max number usage - if (program.maximum_use_number !== 0) { - const [result] = await rpc - .query({ - model: 'coupon.program', - method: 'read', - args: [program.id, ['total_order_count']], - kwargs: { context: session.user_context }, - }) - .catch(() => Promise.resolve([false])); // may happen because offline - if (!result) { - return { - successful: false, - reason: 'Unable to get the number of usage of the program.', - }; - } else if (!(result.total_order_count < program.maximum_use_number)) { - return { - successful: false, - reason: "Program's maximum number of usage has been reached.", - }; - } - } - - return { - successful: true, - }; - } - /** - * This method is called via `collectRewards` inside `_calculateRewards`. - * The purpose of this method is to create `Reward` objects based on the given - * `program` and `coupon_id`. - * It returns a tuple of rewards and reason if no rewards are created. - * - * @param {coupon.program} program - * @param {number} coupon_id - * @returns {[Reward[], string | null]} - */ - _getProductRewards(program, coupon_id) { - const totalQuantity = this._getRegularOrderlines() - .filter((line) => { - return program.valid_product_ids.has(line.product.id); - }) - .reduce((quantity, line) => quantity + line.quantity, 0); - - const freeQuantity = computeFreeQuantity( - totalQuantity, - program.rule_min_quantity, - program.reward_product_quantity - ); - if (freeQuantity === 0) { - return [[], 'Zero free product quantity.']; - } else { - const rewardProduct = this.pos.db.get_product_by_id(program.reward_product_id[0]); - const discountLineProduct = this.pos.db.get_product_by_id(program.discount_line_product_id[0]); - return [ - [ - new Reward({ - product: discountLineProduct, - unit_price: -rewardProduct.lst_price, - quantity: freeQuantity, - program: program, - tax_ids: rewardProduct.taxes_id, - coupon_id: coupon_id, - }), - ], - null, - ]; - } - } - /** - * This method is called via `collectRewards` inside `_calculateRewards`. - * It returns fixed discount reward based on the given program. - * - * @param {coupon.program} program - * @param {number} coupon_id - * @returns {[Reward[], string | null]} - */ - _getFixedDiscount(program, coupon_id) { - const discountAmount = Math.min(program.discount_fixed_amount, program.discount_max_amount || Infinity); - return [ - [ - new Reward({ - product: this.pos.db.get_product_by_id(program.discount_line_product_id[0]), - unit_price: -discountAmount, - quantity: 1, - program: program, - tax_ids: [], - coupon_id: coupon_id, - }), - ], - null, - ]; - } - /** - * This method is called via `collectRewards` inside `_calculateRewards`. - * This returns discount rewards based on the program's specific products. - * Amounts are grouped based on products tax ids (see `_getGroupKey`). - * We also adjust the `amountsToDiscount` based on the rewarded products. - * - * @param {coupon.program} program - * @param {number} coupon_id - * @param {Reward[]} productRewards - * @returns {[Reward[], string | null]} - */ - _getSpecificDiscount(program, coupon_id, productRewards) { - const productIdsToAccount = new Set(); - const amountsToDiscount = {}; - for (let line of this._getRegularOrderlines()) { - if (program.discount_specific_product_ids.has(line.get_product().id)) { - const key = this._getGroupKey(line); - if (!(key in amountsToDiscount)) { - amountsToDiscount[key] = line.get_quantity() * line.price; - } else { - amountsToDiscount[key] += line.get_quantity() * line.price; - } - productIdsToAccount.add(line.get_product().id); - } - } - this._considerProductRewards(amountsToDiscount, productIdsToAccount, productRewards); - return this._createDiscountRewards(program, coupon_id, amountsToDiscount); - } - /** - * This method is called via `collectRewards` inside `_calculateRewards`. - * It returns a discount reward for the cheapest item in the order. Cheapest - * item is a single quantity with lowest price. - * - * @param {coupon.program} program - * @param {number} coupon_id - * @returns {[Reward[], string | null]} - */ - _getOnCheapestProductDiscount(program, coupon_id) { - const amountsToDiscount = {}; - const orderlines = this._getRegularOrderlines(); - if (orderlines.length > 0) { - const cheapestLine = orderlines.reduce((min_line, line) => { - if (line.price < min_line.price) { - return line; - } else { - return min_line; - } - }, orderlines[0]); - const key = this._getGroupKey(cheapestLine); - amountsToDiscount[key] = cheapestLine.price; - } - return this._createDiscountRewards(program, coupon_id, amountsToDiscount); - } - /** - * This method is called via `collectRewards` inside `_calculateRewards`. - * This returns discount rewards based on all the orderlines. Amounts are grouped based - * on products tax ids (see `_getGroupKey`). `amountsToDiscount` is adjusted - * based on the rewarded products. - * - * @param {coupon.program} program - * @param {number} coupon_id - * @param {Reward[]} productRewards - * @returns {[Reward[], string | null]} - */ - _getOnOrderDiscountRewards(program, coupon_id, productRewards) { - const productIdsToAccount = new Set(); - const amountsToDiscount = {}; - for (let line of this._getRegularOrderlines()) { - const key = this._getGroupKey(line); - if (!(key in amountsToDiscount)) { - amountsToDiscount[key] = line.get_quantity() * line.price; - } else { - amountsToDiscount[key] += line.get_quantity() * line.price; - } - productIdsToAccount.add(line.get_product().id); - } - this._considerProductRewards(amountsToDiscount, productIdsToAccount, productRewards); - return this._createDiscountRewards(program, coupon_id, amountsToDiscount); - } - /** - * Mutates `amountsToDiscount` to take into account the product rewards. - * - * @param {Record} amountsToDiscount - * @param {Set} productIdsToAccount - * @param {Reward[]} productRewards - */ - _considerProductRewards(amountsToDiscount, productIdsToAccount, productRewards) { - for (let reward of productRewards) { - if (reward.rewardedProductId && productIdsToAccount.has(reward.rewardedProductId)) { - const key = reward.tax_ids.join(','); - amountsToDiscount[key] += reward.quantity * reward.unit_price; - } - } - } - _getGroupKey(line) { - return line - .get_taxes() - .map((tax) => tax.id) - .join(','); - } - _createDiscountRewards(program, coupon_id, amountsToDiscount) { - const discountRewards = Object.entries(amountsToDiscount).map(([tax_keys, amount]) => { - let discountAmount = (amount * program.discount_percentage) / 100.0; - discountAmount = Math.min(discountAmount, program.discount_max_amount || Infinity); - return new Reward({ - product: this.pos.db.get_product_by_id(program.discount_line_product_id[0]), - unit_price: -discountAmount, - quantity: 1, - program: program, - tax_ids: tax_keys !== '' ? tax_keys.split(',').map((val) => parseInt(val, 10)) : [], - coupon_id: coupon_id, - }); - }); - return [discountRewards, discountRewards.length > 0 ? null : 'No items to discount.']; - } - } - Registries.Model.extend(Order, PosCouponOrder); - - - const PosCouponOrderline = (Orderline) => class PosCouponOrderline extends Orderline { - export_as_JSON() { - var result = super.export_as_JSON(...arguments); - result.is_program_reward = this.is_program_reward; - result.program_id = this.program_id; - result.coupon_id = this.coupon_id; - return result; - } - init_from_JSON(json) { - if (json.is_program_reward) { - this.is_program_reward = json.is_program_reward; - this.program_id = json.program_id; - this.coupon_id = json.coupon_id; - if (this.coupon_id && this.coupon_id[1]) { - this.order.bookedCouponCodes[this.coupon_id[1]] = new CouponCode(this.coupon_id[1], this.coupon_id[0], this.program_id[0]); - } else if (json.program_id && json.program_id[0]) { - this.order.activePromoProgramIds.push(json.program_id[0]); - } - } - super.init_from_JSON(...arguments); - } - set_quantity(quantity, keep_price) { - const result = super.set_quantity(...arguments); - // This function removes an order line if we set the quantity to 'remove' - // We extend its functionality so that if a reward line is removed, - // other reward lines from the same program are also deleted. - if (quantity === 'remove' && this.is_program_reward) { - let related_rewards = this.order.orderlines.filter( - (line) => line.is_program_reward && line.program_id === this.program_id - ); - for (let line of related_rewards) { - line.order.remove_orderline(line); - } - if (related_rewards.length !== 0) { - Gui.showNotification('Other reward lines from the same program were also removed.'); - } - } - return result; - } - } - Registries.Model.extend(Orderline, PosCouponOrderline); -}); diff --git a/addons/pos_coupon/static/src/js/tours/PosCoupon1.tour.js b/addons/pos_coupon/static/src/js/tours/PosCoupon1.tour.js deleted file mode 100644 index 77c2e7d2100..00000000000 --- a/addons/pos_coupon/static/src/js/tours/PosCoupon1.tour.js +++ /dev/null @@ -1,77 +0,0 @@ -odoo.define('pos_coupon.tour.pos_coupon1', function (require) { - 'use strict'; - - // --- PoS Coupon Tour Basic Part 1 --- - // Generate coupons for PosCouponTour2. - - const { PosCoupon } = require('pos_coupon.tour.PosCouponTourMethods'); - const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); - const { getSteps, startSteps } = require('point_of_sale.tour.utils'); - var Tour = require('web_tour.tour'); - - startSteps(); - - ProductScreen.do.confirmOpeningPopup(); - ProductScreen.do.clickHomeCategory(); - - // basic order - // just accept the automatically applied promo program - // applied programs: - // - on cheapest product - ProductScreen.exec.addOrderline('Whiteboard Pen', '5'); - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.88'); - PosCoupon.do.selectRewardLine('on cheapest product'); - PosCoupon.check.orderTotalIs('13.12'); - PosCoupon.exec.finalizeOrder('Cash', '20'); - - // remove the reward from auto promo program - // no applied programs - ProductScreen.exec.addOrderline('Whiteboard Pen', '6'); - PosCoupon.check.hasRewardLine('on cheapest product', '-2.88'); - PosCoupon.check.orderTotalIs('16.32'); - PosCoupon.exec.removeRewardLine('90.0% discount on cheapest product'); - PosCoupon.check.orderTotalIs('19.2'); - PosCoupon.exec.finalizeOrder('Cash', '20'); - - // order with coupon code from coupon program - // applied programs: - // - coupon program - ProductScreen.exec.addOrderline('Desk Organizer', '9'); - PosCoupon.check.hasRewardLine('on cheapest product', '-4.59'); - PosCoupon.exec.removeRewardLine('90.0% discount on cheapest product'); - PosCoupon.check.orderTotalIs('45.90'); - PosCoupon.do.enterCode('invalid_code'); - PosCoupon.do.enterCode('1234'); - PosCoupon.check.hasRewardLine('Free Product - Desk Organizer', '-15.30'); - PosCoupon.exec.finalizeOrder('Cash', '50'); - - // Use coupon but eventually remove the reward - // applied programs: - // - on cheapest product - ProductScreen.exec.addOrderline('Letter Tray', '4'); - ProductScreen.exec.addOrderline('Desk Organizer', '9'); - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-4.75'); - PosCoupon.check.orderTotalIs('62.27'); - PosCoupon.do.enterCode('5678'); - PosCoupon.check.hasRewardLine('Free Product - Desk Organizer', '-15.30'); - PosCoupon.check.orderTotalIs('46.97'); - PosCoupon.exec.removeRewardLine('Free Product - Desk Organizer'); - PosCoupon.check.orderTotalIs('62.27'); - PosCoupon.exec.finalizeOrder('Cash', '90'); - - // specific product discount - // applied programs: - // - on cheapest product - // - on specific products - ProductScreen.exec.addOrderline('Magnetic Board', '10') // 1.98 - ProductScreen.exec.addOrderline('Desk Organizer', '3') // 5.1 - ProductScreen.exec.addOrderline('Letter Tray', '4') // 4.8 tax 10% - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-1.78') - PosCoupon.check.orderTotalIs('54.44') - PosCoupon.do.enterCode('promocode') - PosCoupon.check.hasRewardLine('50.0% discount on products', '-17.55') - PosCoupon.check.orderTotalIs('36.89') - PosCoupon.exec.finalizeOrder('Cash', '50') - - Tour.register('PosCouponTour1', { test: true, url: '/pos/web' }, getSteps()); -}); diff --git a/addons/pos_coupon/static/src/js/tours/PosCoupon2.tour.js b/addons/pos_coupon/static/src/js/tours/PosCoupon2.tour.js deleted file mode 100644 index 96e7a1f376f..00000000000 --- a/addons/pos_coupon/static/src/js/tours/PosCoupon2.tour.js +++ /dev/null @@ -1,110 +0,0 @@ -odoo.define('pos_coupon.tour.pos_coupon2', function (require) { - 'use strict'; - - // --- PoS Coupon Tour Basic Part 2 --- - // Using the coupons generated from PosCouponTour1. - - const { PosCoupon } = require('pos_coupon.tour.PosCouponTourMethods'); - const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); - const { getSteps, startSteps } = require('point_of_sale.tour.utils'); - var Tour = require('web_tour.tour'); - - startSteps(); - - ProductScreen.do.clickHomeCategory(); - - // Cheapest product discount should be replaced by the global discount - // because it's amount is lower. - // Applied programs: - // - global discount - ProductScreen.exec.addOrderline('Desk Organizer', '10'); // 5.1 - PosCoupon.check.hasRewardLine('on cheapest product', '-4.59'); - ProductScreen.exec.addOrderline('Letter Tray', '4'); // 4.8 tax 10% - PosCoupon.check.hasRewardLine('on cheapest product', '-4.75'); - PosCoupon.do.enterCode('123456'); - PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-5.10'); - PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-2.11'); - PosCoupon.check.orderTotalIs('64.91'); - PosCoupon.exec.finalizeOrder('Cash', '70'); - - // Use coupon from global discount but on cheapest discount prevails. - // The global discount coupon should be consumed during the order as it is - // activated in the order. But upon validation, the coupon should return - // to new state. - // Applied programs: - // - on cheapest discount - ProductScreen.exec.addOrderline('Small Shelf', '3'); // 2.83 per item - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.55'); - PosCoupon.do.enterCode('345678'); - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.55'); - ProductScreen.exec.addOrderline('Desk Organizer', '9'); // 4.80 per item - PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-5.44'); - ProductScreen.do.pressNumpad('Backspace Backspace') - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.55'); - ProductScreen.exec.addOrderline('Desk Pad', '1'); // 1.98 - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-1.78'); - PosCoupon.check.orderTotalIs('8.69'); - PosCoupon.exec.finalizeOrder('Cash', '10'); - - // Scanning coupon twice. - // Also apply global discount on top of free product to check if the - // calculated discount is correct. - // Applied programs: - // - coupon program (free product) - // - global discount - ProductScreen.exec.addOrderline('Desk Organizer', '11'); // 5.1 per item - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-4.59'); - PosCoupon.check.orderTotalIs('51.51'); - // add global discount and the discount will be replaced - PosCoupon.do.enterCode('345678'); - PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-5.61'); - // add free product coupon (for qty=11, free=4) - // the discount should change after having free products - // it should go back to cheapest discount as it is higher - PosCoupon.do.enterCode('5678'); - PosCoupon.check.hasRewardLine('Free Product - Desk Organizer', '-20.40'); - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-4.59'); - // set quantity to 18 - // should result to 'charged qty'=12, 'free qty'=6 - ProductScreen.do.pressNumpad('Backspace 8') - PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-6.12'); - PosCoupon.check.hasRewardLine('Free Product - Desk Organizer', '-30.60'); - // scan the code again and check notification - PosCoupon.do.enterCode('5678'); - PosCoupon.check.orderTotalIs('55.08'); - PosCoupon.exec.finalizeOrder('Cash', '60'); - - // Specific products discount (with promocode) and free product (1357) - // Applied programs: - // - discount on specific products - // - free product - ProductScreen.exec.addOrderline('Desk Organizer', '6'); // 5.1 per item - PosCoupon.check.hasRewardLine('on cheapest product', '-4.59'); - PosCoupon.exec.removeRewardLine('90.0% discount on cheapest product'); - PosCoupon.do.enterCode('promocode'); - PosCoupon.check.hasRewardLine('50.0% discount on products', '-15.30'); - PosCoupon.do.enterCode('1357'); - PosCoupon.check.hasRewardLine('Free Product - Desk Organizer', '-10.20'); - PosCoupon.check.hasRewardLine('50.0% discount on products', '-10.20'); - PosCoupon.check.orderTotalIs('10.20'); - PosCoupon.exec.finalizeOrder('Cash', '20'); - - // Check reset program - // Enter two codes and reset the programs. - // The codes should be checked afterwards. They should return to new. - // Applied programs: - // - cheapest product - PosCoupon.do.enterCode('2468'); - PosCoupon.do.enterCode('098765'); - ProductScreen.exec.addOrderline('Monitor Stand', '6'); // 3.19 per item - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.87'); - PosCoupon.check.orderTotalIs('16.27'); - PosCoupon.exec.removeRewardLine('90.0% discount on cheapest product'); - PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-1.91'); - PosCoupon.do.resetActivePrograms(); - PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.87'); - PosCoupon.check.orderTotalIs('16.27'); - PosCoupon.exec.finalizeOrder('Cash', '20'); - - Tour.register('PosCouponTour2', { test: true, url: '/pos/web' }, getSteps()); -}); diff --git a/addons/pos_coupon/static/src/xml/OrderReceipt.xml b/addons/pos_coupon/static/src/xml/OrderReceipt.xml deleted file mode 100644 index 302d94f184d..00000000000 --- a/addons/pos_coupon/static/src/xml/OrderReceipt.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - -
-
------------------------
-
-
- Coupon Codes -
- -
-
- -
-
- Valid until: - - - - - no expiration - -
-
- Barcode -
-
- -
-
-
-
-
-
-
- -
diff --git a/addons/pos_coupon/static/src/xml/OrderWidget.xml b/addons/pos_coupon/static/src/xml/OrderWidget.xml deleted file mode 100644 index bd2988dd702..00000000000 --- a/addons/pos_coupon/static/src/xml/OrderWidget.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - -
-
-
Active Programs
- -
- - - () - -
-
- -
- () -
-
- -
- - - () - -
-
-
-
-
-
- -
diff --git a/addons/pos_coupon/tests/test_frontend.py b/addons/pos_coupon/tests/test_frontend.py deleted file mode 100644 index e8626f8e773..00000000000 --- a/addons/pos_coupon/tests/test_frontend.py +++ /dev/null @@ -1,170 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon -from odoo.tests import Form, tagged - - -@tagged("post_install", "-at_install") -class TestUi(TestPointOfSaleHttpCommon): - def setUp(self): - super().setUp() - - self.promo_programs = self.env["coupon.program"] - - # code promo program -> discount on specific products - self.code_promo_program = self.env["coupon.program"].create( - { - "name": "Promo Code Program - Discount on Specific Products", - "program_type": "promotion_program", - "promo_code_usage": "code_needed", - "promo_code": "promocode", - "discount_apply_on": "specific_products", - "discount_percentage": 50, - "discount_specific_product_ids": ( - self.whiteboard_pen | self.magnetic_board | self.desk_organizer - ).ids, - } - ) - self.promo_programs |= self.code_promo_program - - # auto promo program on current order - # -> discount on cheapest product - self.auto_promo_program_current = self.env["coupon.program"].create( - { - "name": "Auto Promo Program - Cheapest Product", - "program_type": "promotion_program", - "promo_code_usage": "no_code_needed", - "discount_apply_on": "cheapest_product", - "discount_percentage": 90, - } - ) - self.promo_programs |= self.auto_promo_program_current - - # auto promo program on next order - # -> discount on order (global discount) - self.auto_promo_program_next = self.env["coupon.program"].create( - { - "name": "Auto Promo Program - Global Discount", - "program_type": "promotion_program", - "promo_code_usage": "no_code_needed", - "promo_applicability": "on_next_order", - "discount_apply_on": "on_order", - "discount_percentage": 10, - } - ) - self.promo_programs |= self.auto_promo_program_next - - # coupon program -> free product - self.coupon_program = self.env["coupon.program"].create( - { - "name": "Coupon Program - Buy 3 Take 2 Free Product", - "program_type": "coupon_program", - "rule_products_domain": "[('name', 'ilike', 'Desk Organizer')]", - "reward_type": "product", - "rule_min_quantity": 3, - "reward_product_id": self.desk_organizer.id, - "reward_product_quantity": 2, - } - ) - - # Create coupons for the coupon program and change the code - # to be able to use them in the frontend tour. - self.env["coupon.generate.wizard"].with_context( - {"active_id": self.coupon_program.id} - ).create({"nbr_coupons": 4}).generate_coupon() - ( - self.coupon1, - self.coupon2, - self.coupon3, - self.coupon4, - ) = self.coupon_program.coupon_ids - self.coupon1.write({"code": "1234"}) - self.coupon2.write({"code": "5678"}) - self.coupon3.write({"code": "1357"}) - self.coupon4.write({"code": "2468"}) - - def test_pos_coupon_tour_basic(self): - """PoS Coupon Basic Tour""" - - # Set the programs to the pos config. - # Remove fiscal position and pricelist. - with Form(self.main_pos_config) as pos_config: - pos_config.tax_regime_selection = False - pos_config.use_pricelist = False - pos_config.pricelist_id = self.env["product.pricelist"].create( - {"name": "PoS Default Pricelist",} - ) - pos_config.use_coupon_programs = True - pos_config.coupon_program_ids.add(self.coupon_program) - for promo_program in self.promo_programs: - pos_config.promo_program_ids.add(promo_program) - - self.main_pos_config.open_session_cb() - - ## - # Tour Part 1 - # This part will generate coupons for `auto_promo_program_next` - # that will be used in the second part of the tour. - # - - self.start_tour( - "/pos/web?config_id=%d" % self.main_pos_config.id, - "PosCouponTour1", - login="accountman", - ) - - # check coupon usage - self.assertEqual( - self.coupon1.state, "used", msg="`1234` coupon should have been used." - ) - self.assertEqual( - self.coupon2.state, - "new", - msg="`5678` coupon code is used but was eventually freed.", - ) - # check pos_order_count in each program - self.assertEqual(self.auto_promo_program_current.pos_order_count, 3) - self.assertEqual(self.auto_promo_program_next.pos_order_count, 0) - self.assertEqual(self.code_promo_program.pos_order_count, 1) - self.assertEqual(self.coupon_program.pos_order_count, 1) - # check number of generated coupons - self.assertEqual(len(self.auto_promo_program_next.coupon_ids), 5) - # check number of orders in the session - pos_session = self.main_pos_config.current_session_id - self.assertEqual( - len(pos_session.order_ids), 5, msg="5 orders were made in tour part1." - ) - - ## - # Tour Part 2 - # The coupons generated in the first part will be used in this tour. - # - - # Manually set the code for some `auto_promo_program_next` coupons - # to be able to use them in defining the part2 tour. - ( - promo_coupon1, - promo_coupon2, - promo_coupon3, - promo_coupon4, - *_, - ) = self.auto_promo_program_next.coupon_ids - promo_coupon1.write({"code": "123456"}) - promo_coupon2.write({"code": "345678"}) - promo_coupon3.write({"code": "567890"}) - promo_coupon4.write({"code": "098765"}) - - # use here the generated coupon - self.start_tour( - "/pos/web?config_id=%d" % self.main_pos_config.id, - "PosCouponTour2", - login="accountman", - ) - self.assertEqual(self.coupon4.state, "new") - self.assertEqual(promo_coupon4.state, "new") - # check pos_order_count in each program - self.assertEqual(self.auto_promo_program_current.pos_order_count, 5) - self.assertEqual(self.auto_promo_program_next.pos_order_count, 2) - self.assertEqual(self.code_promo_program.pos_order_count, 2) - self.assertEqual(self.coupon_program.pos_order_count, 3) diff --git a/addons/pos_coupon/views/coupon_program_views.xml b/addons/pos_coupon/views/coupon_program_views.xml deleted file mode 100644 index eb7bd3fbf17..00000000000 --- a/addons/pos_coupon/views/coupon_program_views.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - coupon.program.form - coupon.program - - - - - - - - - - coupon.program.form - coupon.program - - - - - - - - - - - diff --git a/addons/pos_coupon/views/coupon_views.xml b/addons/pos_coupon/views/coupon_views.xml deleted file mode 100644 index 6de4678c280..00000000000 --- a/addons/pos_coupon/views/coupon_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - coupon.coupon.form - coupon.coupon - - - - - - - - - - - - diff --git a/addons/pos_coupon/views/res_config_settings_views.xml b/addons/pos_coupon/views/res_config_settings_views.xml deleted file mode 100644 index 8eabf8aeb79..00000000000 --- a/addons/pos_coupon/views/res_config_settings_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - res.config.form.inherit.pos.coupon - res.config.settings - - - -
-
-
-
-
-
-
-
diff --git a/addons/pos_gift_card/__manifest__.py b/addons/pos_gift_card/__manifest__.py deleted file mode 100644 index d248853a47c..00000000000 --- a/addons/pos_gift_card/__manifest__.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -{ - 'name': "Gift Card for point of sales module", - 'summary': "Use gift card in your sales orders", - 'description': """Integrate gift card mechanism in sales orders.""", - 'category': 'Sales/Sales', - 'version': '1.0', - 'depends': ['gift_card', 'point_of_sale'], - 'auto_install': True, - 'data': [ - 'data/gift_card_data.xml', - 'views/gift_card_views.xml', - 'views/res_config_settings_views.xml', - 'views/pos_config_views.xml', - 'security/ir.model.access.csv', - ], - 'assets': { - 'point_of_sale.assets': [ - 'pos_gift_card/static/src/css/giftCard.css', - 'pos_gift_card/static/src/js/models.js', - 'pos_gift_card/static/src/js/GiftCardButton.js', - 'pos_gift_card/static/src/js/GiftCardPopup.js', - 'pos_gift_card/static/src/js/PaymentScreen.js', - ], - 'web.assets_qweb': [ - 'pos_gift_card/static/src/xml/**/*', - ], - }, - 'license': 'LGPL-3', -} diff --git a/addons/pos_gift_card/data/gift_card_data.xml b/addons/pos_gift_card/data/gift_card_data.xml deleted file mode 100644 index 4ec5db91191..00000000000 --- a/addons/pos_gift_card/data/gift_card_data.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - True - - - - Gift Card - gift.card - qweb-pdf - pos_gift_card.gift_card_template - report - - - - - - diff --git a/addons/pos_gift_card/i18n/pos_gift_card.pot b/addons/pos_gift_card/i18n/pos_gift_card.pot deleted file mode 100644 index 64be6360b22..00000000000 --- a/addons/pos_gift_card/i18n/pos_gift_card.pot +++ /dev/null @@ -1,282 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * pos_gift_card -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server saas~15.2\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-02-11 14:34+0000\n" -"PO-Revision-Date: 2022-02-11 14:34+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: pos_gift_card -#: model_terms:ir.ui.view,arch_db:pos_gift_card.gift_card_template -msgid "Gift Card Code" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Amount of the gift card:" -msgstr "" - -#. module: pos_gift_card -#: model_terms:ir.ui.view,arch_db:pos_gift_card.gift_card_template -msgid "Barcode" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model,name:pos_gift_card.model_barcode_rule -msgid "Barcode Rule" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,field_description:pos_gift_card.field_pos_order_line__generated_gift_card_ids -msgid "Bought Gift Card" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,field_description:pos_gift_card.field_gift_card__buy_pos_order_line_id -msgid "Buy Pos Order Line" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Cancel" -msgstr "" - -#. module: pos_gift_card -#: model_terms:ir.ui.view,arch_db:pos_gift_card.gift_card_template -msgid "Card expires" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Check a gift card" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Confirm" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,help:pos_gift_card.field_pos_order_line__gift_card_id -msgid "Deducted from this Gift Card" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,help:pos_gift_card.field_pos_config__gift_card_settings -msgid "Defines the way you want to set your gift cards." -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Discard" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields.selection,name:pos_gift_card.selection__pos_config__gift_card_settings__create_set -msgid "Generate a new barcode and set a price" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Generate barcode" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardButton.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: model:ir.actions.report,name:pos_gift_card.gift_card_report_pdf -#: model:ir.model,name:pos_gift_card.model_gift_card -#: model:ir.model.fields,field_description:pos_gift_card.field_pos_config__use_gift_card -#: model:ir.model.fields,field_description:pos_gift_card.field_pos_order_line__gift_card_id -#: model:ir.model.fields.selection,name:pos_gift_card.selection__barcode_rule__type__gift_card -#: model_terms:ir.ui.view,arch_db:pos_gift_card.pos_gift_card_config_view_form -#: model_terms:ir.ui.view,arch_db:pos_gift_card.res_config_view_form_inherit_pos_coupon -#, python-format -msgid "Gift Card" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Gift Card Barcode:" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,field_description:pos_gift_card.field_pos_order__gift_card_count -msgid "Gift Card Count" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/js/PaymentScreen.js:0 -#: code:addons/pos_gift_card/static/src/js/PaymentScreen.js:0 -#, python-format -msgid "Gift Card Error" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,field_description:pos_gift_card.field_pos_config__gift_card_product_id -#: model_terms:ir.ui.view,arch_db:pos_gift_card.pos_gift_card_config_view_form -msgid "Gift Card Product" -msgstr "" - -#. module: pos_gift_card -#: model:ir.ui.menu,name:pos_gift_card.pos_gift_card_menu -msgid "Gift Cards" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,field_description:pos_gift_card.field_pos_config__gift_card_settings -msgid "Gift Cards settings" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/js/PaymentScreen.js:0 -#, python-format -msgid "Gift card balance is too low." -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/js/PaymentScreen.js:0 -#, python-format -msgid "Gift card is not valid." -msgstr "" - -#. module: pos_gift_card -#: model_terms:ir.ui.view,arch_db:pos_gift_card.pos_gift_card_config_view_form -msgid "Gift card settings" -msgstr "" - -#. module: pos_gift_card -#: model_terms:ir.ui.view,arch_db:pos_gift_card.gift_card_template -msgid "Here is your gift card!" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model,name:pos_gift_card.model_pos_config -msgid "Point of Sale Configuration" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model,name:pos_gift_card.model_pos_order_line -msgid "Point of Sale Order Lines" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model,name:pos_gift_card.model_pos_order -msgid "Point of Sale Orders" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model,name:pos_gift_card.model_pos_session -msgid "Point of Sale Session" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,help:pos_gift_card.field_gift_card__buy_pos_order_line_id -msgid "Pos Order line where this gift card has been bought." -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,field_description:pos_gift_card.field_gift_card__redeem_pos_order_line_ids -msgid "Pos Redeems" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Remaining amount of the gift card:" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields.selection,name:pos_gift_card.selection__pos_config__gift_card_settings__scan_set -msgid "Scan an existing barcode and set a price" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields.selection,name:pos_gift_card.selection__pos_config__gift_card_settings__scan_use -msgid "Scan an existing barcode with an existing price" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Scan and set price on gift card" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Scan gift card" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/js/GiftCardPopup.js:0 -#, python-format -msgid "This gift card has already been sold" -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,help:pos_gift_card.field_pos_config__gift_card_product_id -msgid "This product is used as reference on customer receipts." -msgstr "" - -#. module: pos_gift_card -#: model:ir.model.fields,field_description:pos_gift_card.field_barcode_rule__type -msgid "Type" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/xml/GiftCardPopup.xml:0 -#, python-format -msgid "Use a gift card" -msgstr "" - -#. module: pos_gift_card -#. openerp-web -#: code:addons/pos_gift_card/static/src/js/GiftCardPopup.js:0 -#, python-format -msgid "You cannot sell a gift card that has already been sold" -msgstr "" diff --git a/addons/pos_gift_card/models/gift_card.py b/addons/pos_gift_card/models/gift_card.py deleted file mode 100644 index fcde18d10c8..00000000000 --- a/addons/pos_gift_card/models/gift_card.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models - - -class GiftCard(models.Model): - _inherit = "gift.card" - - buy_pos_order_line_id = fields.Many2one( - "pos.order.line", - copy=False, - readonly=True, - help="Pos Order line where this gift card has been bought.", - ) - redeem_pos_order_line_ids = fields.One2many( - "pos.order.line", "gift_card_id", string="Pos Redeems" - ) - - @api.depends("redeem_pos_order_line_ids") - def _compute_balance(self): - super()._compute_balance() - for record in self: - confirmed_line = record.redeem_pos_order_line_ids.sudo().filtered( - lambda l: l.order_id.state in ('paid', 'done', 'invoiced') - ) - balance = record.balance - if confirmed_line: - balance -= sum( - confirmed_line.mapped( - lambda line: line.currency_id._convert( - line.price_unit, - record.currency_id, - record.env.company, - line.create_date, - ) - * -1 - ) - ) - record.balance = balance - - def can_be_used_in_pos(self): - # expired state are computed once a day, so can be not synchro - return self.state == 'valid' and self.balance > 0 and self.expired_date >= fields.Date.today() diff --git a/addons/pos_gift_card/models/pos_config.py b/addons/pos_gift_card/models/pos_config.py deleted file mode 100644 index ef55d864364..00000000000 --- a/addons/pos_gift_card/models/pos_config.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models - - -class PosConfig(models.Model): - _inherit = "pos.config" - - use_gift_card = fields.Boolean(string="Gift Card") - - gift_card_product_id = fields.Many2one( - "product.product", - string="Gift Card Product", - help="This product is used as reference on customer receipts.", - ) - - gift_card_settings = fields.Selection( - [ - ("create_set", "Generate a new barcode and set a price"), - ("scan_set", "Scan an existing barcode and set a price"), - ("scan_use", "Scan an existing barcode with an existing price"), - ], - string="Gift Cards settings", - default="create_set", - help="Defines the way you want to set your gift cards.", - ) - - @api.onchange("use_gift_card") - def _onchange_giftproduct(self): - if self.use_gift_card: - self.gift_card_product_id = self.env.ref( - "gift_card.pay_with_gift_card_product", False - ) - else: - self.gift_card_product_id = False diff --git a/addons/pos_gift_card/models/pos_order.py b/addons/pos_gift_card/models/pos_order.py deleted file mode 100644 index dace6d2a7da..00000000000 --- a/addons/pos_gift_card/models/pos_order.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models -import base64 - - -class PosOrder(models.Model): - _inherit = "pos.order" - - gift_card_count = fields.Integer(compute="_compute_gift_card_count") - - @api.depends("lines.generated_gift_card_ids") - def _compute_gift_card_count(self): - for record in self: - record.gift_card_count = len(record.lines.mapped("generated_gift_card_ids")) - - @api.model - def create_from_ui(self, orders, draft=False): - order_ids = super(PosOrder, self).create_from_ui(orders, draft) - for order in self.sudo().browse([o["id"] for o in order_ids]): - gift_card_config = order.config_id.gift_card_settings - for line in order.lines: - if line.product_id.id == order.config_id.gift_card_product_id.id: - if not line.gift_card_id: - if gift_card_config == "create_set": - new_card = line._create_gift_cards() - new_card.partner_id = order.partner_id or False - line.generated_gift_card_ids = new_card - else: - gift_card = self.env["gift.card"].search( - [("id", "=", line.generated_gift_card_ids.id)] - ) - gift_card.buy_pos_order_line_id = line.id - gift_card.expired_date = fields.Date.add( - fields.Date.today(), years=1 - ) - gift_card.partner_id = order.partner_id or False - - if gift_card_config == "scan_set": - gift_card.initial_amount = line.price_unit - - return order_ids - - def get_new_card_ids(self): - return self.lines.mapped("generated_gift_card_ids").ids - - def _add_mail_attachment(self, name, ticket): - attachment = super()._add_mail_attachment(name, ticket) - if self.config_id.use_gift_card and len(self.get_new_card_ids()) > 0: - report = self.env.ref('pos_gift_card.gift_card_report_pdf')._render_qweb_pdf(self.get_new_card_ids()) - filename = name + '.pdf' - gift_card = self.env['ir.attachment'].create({ - 'name': filename, - 'type': 'binary', - 'datas': base64.b64encode(report[0]), - 'store_fname': filename, - 'res_model': 'pos.order', - 'res_id': self.ids[0], - 'mimetype': 'application/x-pdf' - }) - attachment += [(4, gift_card.id)] - - return attachment diff --git a/addons/pos_gift_card/models/pos_order_line.py b/addons/pos_gift_card/models/pos_order_line.py deleted file mode 100644 index 0f4bc9e9eb7..00000000000 --- a/addons/pos_gift_card/models/pos_order_line.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import fields, models - - -class PosOrderLine(models.Model): - _inherit = "pos.order.line" - - generated_gift_card_ids = fields.One2many( - "gift.card", "buy_pos_order_line_id", string="Bought Gift Card" - ) - gift_card_id = fields.Many2one( - "gift.card", help="Deducted from this Gift Card", copy=False - ) - - def _is_not_sellable_line(self): - return self.gift_card_id or super()._is_not_sellable_line() - - def _create_gift_cards(self): - return self.env["gift.card"].create( - [self._build_gift_card() for _ in range(int(self.qty))] - ) - - def _build_gift_card(self): - return { - "initial_amount": self.order_id.currency_id._convert( - self.price_unit, - self.company_id.currency_id, - self.company_id, - fields.Date.today(), - ), - "buy_pos_order_line_id": self.id, - } diff --git a/addons/pos_gift_card/models/pos_session.py b/addons/pos_gift_card/models/pos_session.py deleted file mode 100644 index 1907daa83ac..00000000000 --- a/addons/pos_gift_card/models/pos_session.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import models -from odoo.osv.expression import OR - - -class PosSession(models.Model): - _inherit = 'pos.session' - - def _loader_params_product_product(self): - result = super()._loader_params_product_product() - if self.config_id.use_gift_card and self.config_id.gift_card_product_id: - result['search_params']['domain'] = OR([result['search_params']['domain'], [('id', '=', self.config_id.gift_card_product_id.id)]]) - return result diff --git a/addons/pos_gift_card/security/ir.model.access.csv b/addons/pos_gift_card/security/ir.model.access.csv deleted file mode 100644 index 1468d9216f2..00000000000 --- a/addons/pos_gift_card/security/ir.model.access.csv +++ /dev/null @@ -1,3 +0,0 @@ -id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink -access_gift_card_sales,Gift Card Program Salesman,model_gift_card,point_of_sale.group_pos_manager,1,0,0,0 -access_pos_gift_card_manager,Gift Card Program Sale Manager,model_gift_card,point_of_sale.group_pos_manager,1,1,1,1 diff --git a/addons/pos_gift_card/static/src/css/giftCard.css b/addons/pos_gift_card/static/src/css/giftCard.css deleted file mode 100644 index 061367a5bdf..00000000000 --- a/addons/pos_gift_card/static/src/css/giftCard.css +++ /dev/null @@ -1,46 +0,0 @@ -.giftCardPopupMain { - display: flex; - flex-wrap: wrap; - - justify-content: space-around; - - text-align: center; - padding: 1em; -} - -.giftCardPopupContainer { - display: flex; - justify-content: center; - flex-wrap: wrap; - padding: 5px; - - font-weight: bold; - text-align: center; - width: 100%; -} - -.giftCardPopupFooter { - display: flex; - justify-content: space-evenly; - - text-align: center; -} - -.giftCardPopupInput { - border-bottom: 1px solid !important; - background-color: #F0EEEE !important; - box-shadow: none !important; - text-align: right !important; -} - -.giftCardPopupButton { - width: auto !important; - height: auto !important; - padding: 10px; -} - -.giftCardPopupConfirmButton { - flex-grow: 1; - height: auto !important; - padding: 10px; -} diff --git a/addons/pos_gift_card/static/src/js/GiftCardButton.js b/addons/pos_gift_card/static/src/js/GiftCardButton.js deleted file mode 100644 index 8e8da6be5fe..00000000000 --- a/addons/pos_gift_card/static/src/js/GiftCardButton.js +++ /dev/null @@ -1,30 +0,0 @@ -odoo.define("pos_gift_card.GiftCardButton", function (require) { - "use strict"; - - const PosComponent = require("point_of_sale.PosComponent"); - const ProductScreen = require("point_of_sale.ProductScreen"); - const { useListener } = require("@web/core/utils/hooks"); - const Registries = require("point_of_sale.Registries"); - - class GiftCardButton extends PosComponent { - setup() { - super.setup(); - useListener("click", this.onClick); - } - async onClick() { - this.showPopup("GiftCardPopup", {}); - } - } - GiftCardButton.template = "GiftCardButton"; - - ProductScreen.addControlButton({ - component: GiftCardButton, - condition: function () { - return this.env.pos.config.use_gift_card; - }, - }); - - Registries.Component.add(GiftCardButton); - - return GiftCardButton; -}); diff --git a/addons/pos_gift_card/static/src/js/GiftCardPopup.js b/addons/pos_gift_card/static/src/js/GiftCardPopup.js deleted file mode 100644 index 6133057bb9c..00000000000 --- a/addons/pos_gift_card/static/src/js/GiftCardPopup.js +++ /dev/null @@ -1,158 +0,0 @@ -odoo.define("pos_gift_card.GiftCardPopup", function (require) { - "use strict"; - - const AbstractAwaitablePopup = require("point_of_sale.AbstractAwaitablePopup"); - const Registries = require("point_of_sale.Registries"); - - const { useState } = owl; - - class GiftCardPopup extends AbstractAwaitablePopup { - setup() { - super.setup(); - this.state = useState({ - giftCardConfig: this.env.pos.config.gift_card_settings, - showBarcodeGeneration: false, - showNewGiftCardMenu: false, - showUseGiftCardMenu: false, - showGiftCardDetails: false, - amountToSet: 0, - giftCardBarcode: "", - }); - } - - switchBarcodeView() { - this.state.showBarcodeGeneration = !this.state.showBarcodeGeneration; - if (this.state.showUseGiftCardMenu) - this.state.showUseGiftCardMenu = false; - if (this.state.showGiftCardDetails) - this.state.showGiftCardDetails = false; - } - - switchToUseGiftCardMenu() { - this.switchBarcodeView(); - this.state.showUseGiftCardMenu = true; - } - - switchToShowGiftCardDetails() { - this.switchBarcodeView(); - this.state.showGiftCardDetails = true; - } - - async addGiftCardProduct(giftCard) { - let gift = - this.env.pos.db.product_by_id[ - this.env.pos.config.gift_card_product_id[0] - ]; - - let can_be_sold = true; - if (giftCard) { - can_be_sold = !(giftCard.buy_pos_order_line_id || giftCard.buy_line_id); - } - - if (can_be_sold) { - this.env.pos.get_order().add_product(gift, { - price: this.state.amountToSet, - quantity: 1, - merge: false, - generated_gift_card_ids: giftCard ? giftCard.id : false, - extras: { price_manually_set: true }, - }); - } else { - await this.showPopup('ErrorPopup', { - 'title': this.env._t('This gift card has already been sold'), - 'body': this.env._t('You cannot sell a gift card that has already been sold'), - }); - } - } - - async getGiftCard() { - if (this.state.giftCardBarcode == "") return; - - let giftCard = await this.rpc({ - model: "gift.card", - method: "search_read", - args: [[["code", "=", this.state.giftCardBarcode]]], - }); - if (giftCard.length) { - giftCard = giftCard[0]; - } else { - return false; - } - - return giftCard; - } - - async scanAndUseGiftCard() { - let giftCard = await this.getGiftCard(); - if (!giftCard) return; - - if (this.state.giftCardConfig === "scan_use") - this.state.amountToSet = giftCard.initial_amount; - - await this.addGiftCardProduct(giftCard); - this.cancel(); - } - - async generateBarcode() { - await this.addGiftCardProduct(false); - this.confirm(); - } - - async isGiftCardAlreadyUsed() { - let order = this.env.pos.get_order(); - let giftProduct = - this.env.pos.db.product_by_id[ - this.env.pos.config.gift_card_product_id[0] - ]; - - for (let line of order.orderlines) { - if (line.product.id === giftProduct.id && line.price < 0) { - if (line.gift_card_id === await this.getGiftCard().id) return line; - } - } - return false; - } - - getPriceToRemove(giftCard) { - let currentOrder = this.env.pos.get_order(); - return currentOrder.get_total_with_tax() > giftCard.balance - ? -giftCard.balance - : -currentOrder.get_total_with_tax(); - } - - async payWithGiftCard() { - let giftCard = await this.getGiftCard(); - if (!giftCard) return; - - let gift = - this.env.pos.db.product_by_id[ - this.env.pos.config.gift_card_product_id[0] - ]; - - let currentOrder = this.env.pos.get_order(); - let lineUsed = this.isGiftCardAlreadyUsed() - if (lineUsed) currentOrder.remove_orderline(lineUsed); - - currentOrder.add_product(gift, { - price: this.getPriceToRemove(giftCard), - quantity: 1, - merge: false, - gift_card_id: giftCard.id, - }); - - this.cancel(); - } - - async ShowRemainingAmount() { - let giftCard = await this.getGiftCard(); - if (!giftCard) return; - - this.state.amountToSet = giftCard.balance; - } - } - GiftCardPopup.template = "GiftCardPopup"; - - Registries.Component.add(GiftCardPopup); - - return GiftCardPopup; -}); diff --git a/addons/pos_gift_card/static/src/js/PaymentScreen.js b/addons/pos_gift_card/static/src/js/PaymentScreen.js deleted file mode 100644 index 01094035b4f..00000000000 --- a/addons/pos_gift_card/static/src/js/PaymentScreen.js +++ /dev/null @@ -1,80 +0,0 @@ -odoo.define('pos_gift_card.PaymentScreen', function(require) { - "use strict"; - - const PaymentScreen = require('point_of_sale.PaymentScreen'); - const Registries = require('point_of_sale.Registries'); - var core = require('web.core'); - var _t = core._t; - - - const PosGiftCardPaymentScreen = PaymentScreen => class extends PaymentScreen { - //@Override - async validateOrder(isForceValidate) { - if(this.env.pos.config.use_gift_card) { - if (await this._isOrderValid(isForceValidate)) { - try { - let giftProduct = this.env.pos.db.product_by_id[this.env.pos.config.gift_card_product_id[0]]; - - for (let line of this.currentOrder.orderlines) { - if(line.product.id === giftProduct.id && line.price <= 0) { - let is_valid = await this.rpc({ - model: "gift.card", - method: 'can_be_used_in_pos', - args: [line.gift_card_id], - }); - - if(!is_valid) { - await this.showPopup('ErrorPopup', { - 'title': _t("Gift Card Error"), - 'body': _t("Gift card is not valid."), - }); - return; - } - - let gift_card = await this.rpc({ - model: "gift.card", - method: 'search_read', - domain: [['id', '=', line.gift_card_id]], - fields: ['balance'], - }); - - if(Math.abs(line.price) > gift_card[0].balance) { - await this.showPopup('ErrorPopup', { - 'title': _t("Gift Card Error"), - 'body': _t("Gift card balance is too low."), - }); - return; - } - } - } - } catch (_e) { - // do nothing with the error - } - } - } - await super.validateOrder(...arguments); - } - - async _postPushOrderResolve(order, server_ids) { - if(this.env.pos.config.use_gift_card) { - let ids = await this.rpc({ - model: 'pos.order', - method: 'get_new_card_ids', - args: [server_ids] - }); - if (ids.length > 0) { - this.env.legacyActionManager.do_action('pos_gift_card.gift_card_report_pdf', { - additional_context: { - active_ids: [ids], - }, - }); - } - } - return super._postPushOrderResolve(order, server_ids); - } - }; - - Registries.Component.extend(PaymentScreen, PosGiftCardPaymentScreen); - - return PosGiftCardPaymentScreen; -}); diff --git a/addons/pos_gift_card/static/src/js/models.js b/addons/pos_gift_card/static/src/js/models.js deleted file mode 100644 index c56440a9976..00000000000 --- a/addons/pos_gift_card/static/src/js/models.js +++ /dev/null @@ -1,46 +0,0 @@ -odoo.define("pos_gift_card.gift_card", function (require) { - "use strict"; - - const { Order, Orderline } = require("point_of_sale.models"); - const Registries = require('point_of_sale.Registries'); - - - const PosGiftCardOrder = (Order) => class PosGiftCardOrder extends Order { - set_orderline_options(orderline, options) { - super.set_orderline_options(...arguments); - if (options && options.generated_gift_card_ids) { - orderline.generated_gift_card_ids = [options.generated_gift_card_ids]; - } - if (options && options.gift_card_id) { - orderline.gift_card_id = options.gift_card_id; - } - } - wait_for_push_order() { - if(this.pos.config.use_gift_card) { - let giftProduct = this.pos.db.product_by_id[this.pos.config.gift_card_product_id[0]]; - for (let line of this.orderlines) { - if(line.product.id === giftProduct.id) - return true; - } - } - return super.wait_for_push_order(...arguments); - } - } - Registries.Model.extend(Order, PosGiftCardOrder); - - - const PosGiftCardOrderline = (Orderline) => class PosGiftCardOrderline extends Orderline { - export_as_JSON() { - var json = super.export_as_JSON(...arguments); - json.generated_gift_card_ids = this.generated_gift_card_ids; - json.gift_card_id = this.gift_card_id; - return json; - } - init_from_JSON(json) { - super.init_from_JSON(...arguments); - this.generated_gift_card_ids = json.generated_gift_card_ids; - this.gift_card_id = json.gift_card_id; - } - } - Registries.Model.extend(Orderline, PosGiftCardOrderline); -}); diff --git a/addons/pos_gift_card/static/src/xml/GiftCardPopup.xml b/addons/pos_gift_card/static/src/xml/GiftCardPopup.xml deleted file mode 100644 index aea1ffc0478..00000000000 --- a/addons/pos_gift_card/static/src/xml/GiftCardPopup.xml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - diff --git a/addons/pos_gift_card/views/gift_card_views.xml b/addons/pos_gift_card/views/gift_card_views.xml deleted file mode 100644 index 8904e07c1fd..00000000000 --- a/addons/pos_gift_card/views/gift_card_views.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - gift.card.form Website - gift.card - - - - - - - - - - - - - - - - - - - diff --git a/addons/pos_gift_card/views/pos_config_views.xml b/addons/pos_gift_card/views/pos_config_views.xml deleted file mode 100644 index 9ae52ae0a82..00000000000 --- a/addons/pos_gift_card/views/pos_config_views.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - pos.config.form - pos.config - - - -
-
- -
-
-
-
-
-
-
-
diff --git a/addons/pos_gift_card/views/res_config_settings_views.xml b/addons/pos_gift_card/views/res_config_settings_views.xml deleted file mode 100644 index 702b619466d..00000000000 --- a/addons/pos_gift_card/views/res_config_settings_views.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - res.config.form.inherit.pos.coupon - res.config.settings - - - -
-
-
-
-
-
diff --git a/addons/pos_coupon/__init__.py b/addons/pos_loyalty/__init__.py similarity index 100% rename from addons/pos_coupon/__init__.py rename to addons/pos_loyalty/__init__.py diff --git a/addons/pos_loyalty/__manifest__.py b/addons/pos_loyalty/__manifest__.py new file mode 100644 index 00000000000..7074f2e7072 --- /dev/null +++ b/addons/pos_loyalty/__manifest__.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': "Point of Sale - Coupons & Loyalty", + 'version': '2.0', + 'category': 'Sales/Point Of Sale', + 'sequence': 6, + 'summary': 'Use Coupons, Gift Cards and Loyalty programs in Point of Sale', + 'description': '', + 'depends': ['loyalty', 'point_of_sale'], + 'data': [ + 'security/ir.model.access.csv', + 'data/default_barcode_patterns.xml', + 'views/loyalty_card_views.xml', + 'views/loyalty_mail_views.xml', + 'views/pos_config_views.xml', + 'views/pos_loyalty_menu_views.xml', + ], + 'demo': [ + 'data/pos_loyalty_demo.xml', + ], + 'installable': True, + 'assets': { + 'point_of_sale.assets': [ + 'pos_loyalty/static/src/css/Loyalty.scss', + 'pos_loyalty/static/src/js/Loyalty.js', + 'pos_loyalty/static/src/js/Orderline.js', + 'pos_loyalty/static/src/js/OrderSummary.js', + 'pos_loyalty/static/src/js/OrderWidget.js', + 'pos_loyalty/static/src/js/PaymentScreen.js', + 'pos_loyalty/static/src/js/ProductScreen.js', + 'pos_loyalty/static/src/js/ControlButtons/GiftCardButton.js', + 'pos_loyalty/static/src/js/ControlButtons/ResetProgramsButton.js', + 'pos_loyalty/static/src/js/ControlButtons/PromoCodeButton.js', + 'pos_loyalty/static/src/js/ControlButtons/RewardButton.js', + 'pos_loyalty/static/src/js/Popups/GiftCardPopup.js', + ], + 'web.assets_tests': [ + 'pos_loyalty/static/src/js/tours/**/*', + ], + 'web.assets_qweb': [ + 'pos_loyalty/static/src/xml/**/*', + ], + }, + 'license': 'LGPL-3', +} diff --git a/addons/pos_coupon/data/default_barcode_patterns.xml b/addons/pos_loyalty/data/default_barcode_patterns.xml similarity index 68% rename from addons/pos_coupon/data/default_barcode_patterns.xml rename to addons/pos_loyalty/data/default_barcode_patterns.xml index 7b4334954c6..68a73c883d3 100644 --- a/addons/pos_coupon/data/default_barcode_patterns.xml +++ b/addons/pos_loyalty/data/default_barcode_patterns.xml @@ -1,11 +1,12 @@ - Coupon Barcodes + Coupon & Gift Card Barcodes 50 coupon any - 043 + + 043|044 diff --git a/addons/pos_loyalty/data/gift_card_data.xml b/addons/pos_loyalty/data/gift_card_data.xml new file mode 100644 index 00000000000..dd676c142b0 --- /dev/null +++ b/addons/pos_loyalty/data/gift_card_data.xml @@ -0,0 +1,48 @@ + + + + + Gift Card + loyalty.card + qweb-pdf + sale_loyalty.gift_card_template + report + + + + + diff --git a/addons/pos_loyalty/data/pos_loyalty_demo.xml b/addons/pos_loyalty/data/pos_loyalty_demo.xml new file mode 100644 index 00000000000..d9f49ab83c8 --- /dev/null +++ b/addons/pos_loyalty/data/pos_loyalty_demo.xml @@ -0,0 +1,83 @@ + + + + + + 15% on next order + promotion + auto + future + + + + 100 + + + + + percent + 15 + order + + + + + True + + + + + + + + + + + + + + + + + + + + True + 1.20 + Simple Pen + 0.01 + CONS_0002 + + + + + + + + Loyalty Program + loyalty + auto + both + + + + money + 10 + + + + + product + 5 + + + + + + diff --git a/addons/pos_gift_card/models/__init__.py b/addons/pos_loyalty/models/__init__.py similarity index 67% rename from addons/pos_gift_card/models/__init__.py rename to addons/pos_loyalty/models/__init__.py index b35598fd84e..7ca3abc1682 100644 --- a/addons/pos_gift_card/models/__init__.py +++ b/addons/pos_loyalty/models/__init__.py @@ -2,8 +2,11 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import barcode_rule -from . import gift_card +from . import loyalty_card +from . import loyalty_mail +from . import loyalty_program +from . import loyalty_rule from . import pos_config -from . import pos_order from . import pos_order_line +from . import pos_order from . import pos_session diff --git a/addons/pos_gift_card/models/barcode_rule.py b/addons/pos_loyalty/models/barcode_rule.py similarity index 58% rename from addons/pos_gift_card/models/barcode_rule.py rename to addons/pos_loyalty/models/barcode_rule.py index bf4a97eec01..a33a132fb62 100644 --- a/addons/pos_gift_card/models/barcode_rule.py +++ b/addons/pos_loyalty/models/barcode_rule.py @@ -7,8 +7,4 @@ from odoo import models, fields class BarcodeRule(models.Model): _inherit = 'barcode.rule' - type = fields.Selection(selection_add=[ - ('gift_card', 'Gift Card'), - ], ondelete={ - 'gift_card': 'set default', - }) + type = fields.Selection(selection_add=[('coupon', 'Coupon')], ondelete={'coupon': 'set default'}) diff --git a/addons/pos_loyalty/models/loyalty_card.py b/addons/pos_loyalty/models/loyalty_card.py new file mode 100644 index 00000000000..b4e40048e0d --- /dev/null +++ b/addons/pos_loyalty/models/loyalty_card.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + +class LoyaltyCard(models.Model): + _inherit = 'loyalty.card' + + source_pos_order_id = fields.Many2one('pos.order', "PoS Order Reference", + help="PoS order where this coupon was generated.") + + def _get_default_template(self): + self.ensure_one() + if self.source_pos_order_id: + return self.env.ref('pos_loyalty.mail_coupon_template', False) + return super()._get_default_template() + + def _get_mail_partner(self): + return super()._get_mail_partner() or self.source_pos_order_id.partner_id + + def _get_signature(self): + return self.source_pos_order_id.user_id.signature or super()._get_signature() + + def _compute_use_count(self): + super()._compute_use_count() + read_group_res = self.env['pos.order.line']._read_group( + [('coupon_id', 'in', self.ids)], ['id'], ['coupon_id']) + count_per_coupon = {r['coupon_id'][0]: r['coupon_id_count'] for r in read_group_res} + for card in self: + card.use_count += count_per_coupon.get(card.id, 0) diff --git a/addons/pos_loyalty/models/loyalty_mail.py b/addons/pos_loyalty/models/loyalty_mail.py new file mode 100644 index 00000000000..4277cccc2c0 --- /dev/null +++ b/addons/pos_loyalty/models/loyalty_mail.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + +class LoyaltyMail(models.Model): + _inherit = 'loyalty.mail' + + pos_report_print_id = fields.Many2one('ir.actions.report', string="Print Report", domain=[('model', '=', 'loyalty.card')], + help="The report action to be executed when creating a coupon/gift card/loyalty card in the PoS.", + ) diff --git a/addons/pos_loyalty/models/loyalty_program.py b/addons/pos_loyalty/models/loyalty_program.py new file mode 100644 index 00000000000..65c19a20819 --- /dev/null +++ b/addons/pos_loyalty/models/loyalty_program.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, fields, models +from odoo.tools import unique + +class LoyaltyProgram(models.Model): + _inherit = 'loyalty.program' + + pos_config_ids = fields.Many2many('pos.config', string="Point of Sales", readonly=True) + pos_order_count = fields.Integer("PoS Order Count", compute='_compute_pos_order_count') + + def _compute_pos_order_count(self): + read_group_res = self.env['pos.order.line']._read_group( + [('reward_id', 'in', self.reward_ids.ids)], ['reward_id:array_agg'], ['order_id']) + for program in self: + program_reward_ids = program.reward_ids.ids + program.pos_order_count = sum(1 if any(id in group['reward_id'] for id in program_reward_ids) else 0 for group in read_group_res) + + def _compute_total_order_count(self): + super()._compute_total_order_count() + for program in self: + program.total_order_count += program.pos_order_count + + def action_view_pos_orders(self): + self.ensure_one() + pos_order_ids = list(unique(r['order_id'] for r in\ + self.env['pos.order.line'].search_read([('reward_id', 'in', self.reward_ids.ids)], fields=['order_id']))) + return { + 'name': _("PoS Orders"), + 'view_mode': 'tree,form', + 'res_model': 'pos.order', + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', pos_order_ids)], + 'context': dict(self._context, create=False), + } diff --git a/addons/pos_loyalty/models/loyalty_rule.py b/addons/pos_loyalty/models/loyalty_rule.py new file mode 100644 index 00000000000..5bbf45d02bd --- /dev/null +++ b/addons/pos_loyalty/models/loyalty_rule.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models +from odoo.osv import expression + +class LoyaltyRule(models.Model): + _inherit = 'loyalty.rule' + + valid_product_ids = fields.Many2many( + 'product.product', "Valid Products", compute='_compute_valid_product_ids', + help="These are the products that are valid for this rule.") + any_product = fields.Boolean( + compute='_compute_valid_product_ids', help="Technical field, whether all product match") + + promo_barcode = fields.Char("Barcode", compute='_compute_promo_barcode', store=True, readonly=False, + help="A technical field used as an alternative to the promo code. " + "This is automatically generated when the promo code is changed." + ) + + @api.depends('product_ids', 'product_category_id', 'product_tag_id') #TODO later: product tags + def _compute_valid_product_ids(self): + for rule in self: + if rule.product_ids or\ + rule.product_category_id or\ + rule.product_tag_id or\ + rule.product_domain not in ('[]', "[['sale_ok', '=', True]]"): + domain = rule._get_valid_product_domain() + domain = expression.AND([[('available_in_pos', '=', True)], domain]) + rule.valid_product_ids = self.env['product.product'].search(domain) + rule.any_product = False + else: + rule.any_product = True + rule.valid_product_ids = self.env['product.product'] + + @api.depends('code') + def _compute_promo_barcode(self): + for rule in self: + rule.promo_barcode = self.env['loyalty.card']._generate_code() diff --git a/addons/pos_loyalty/models/pos_config.py b/addons/pos_loyalty/models/pos_config.py new file mode 100644 index 00000000000..5ace5c8a6b2 --- /dev/null +++ b/addons/pos_loyalty/models/pos_config.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +class PosConfig(models.Model): + _inherit = 'pos.config' + + def _default_loyalty_program(self): + return self.env['loyalty.program'].search([('program_type', '=', 'loyalty')], limit=1) + + use_coupon_programs = fields.Boolean('Coupons & Promotions', + help="Use coupon and promotion programs in this PoS configuration.") + coupon_program_ids = fields.Many2many( + 'loyalty.program', string="Coupon Programs", domain=[('program_type', '=', 'coupons')], + relation='pos_config_coupon_program_rel') + promo_program_ids = fields.Many2many( + 'loyalty.program', string="Promotion Programs", domain=[('program_type', '=', 'promotion')], + relation='pos_config_promo_program_rel') + + loyalty_program_id = fields.Many2one('loyalty.program', "PoS Loyalty Programs", domain=[('program_type', '=', 'loyalty')], default=_default_loyalty_program) + + use_gift_card = fields.Boolean('Gift Cards') + gift_card_program_id = fields.Many2one('loyalty.program', "PoS Gift Card Program", domain=[('program_type', '=', 'gift_card')]) + gift_card_settings = fields.Selection( + [ + ("create_set", "Generate a new barcode and set a price"), + ("scan_set", "Scan an existing barcode and set a price"), + ("scan_use", "Scan an existing barcode with an existing price"), + ], + string="Gift Cards settings", + default="create_set", + help="Defines the way you want to set your gift cards.", + ) + + # While we select all program types separately they will all behave the same + all_program_ids = fields.Many2many('loyalty.program', compute='_compute_all_programs') + + @api.depends('use_coupon_programs', 'coupon_program_ids', 'promo_program_ids', + 'loyalty_program_id', 'use_gift_card', 'gift_card_program_id') + def _compute_all_programs(self): + for config in self: + programs = self.env['loyalty.program'] + if config.use_coupon_programs: + programs |= config.coupon_program_ids + programs |= config.promo_program_ids + # This may be a separate field on the config but it actually will be handled just like any other program + if config.loyalty_program_id: + programs |= config.loyalty_program_id + # We also include the gift card program to be able to claim the reward (discount) + # This one will behave a little differently as it will display more options + if config.use_gift_card: + programs |= config.gift_card_program_id + config.all_program_ids = programs + + @api.model + def set_loyalty_program_to_main_config(self): + main_config = self.env.ref('point_of_sale.pos_config_main') + default_loyalty_program = self._default_loyalty_program() + main_config.write({'module_pos_loyalty': bool(default_loyalty_program), 'loyalty_program_id': default_loyalty_program.id}) + + def open_session_cb(self): + self.ensure_one() + # Check validity of programs before opening a new session + invalid_reward_products_msg = '' + for reward in self.all_program_ids.reward_ids: + if reward.reward_type == 'product': + for product in reward.reward_product_ids: + if product.available_in_pos: + continue + invalid_reward_products_msg += "\n\t" + invalid_reward_products_msg += _( + "Program: %(name)s, Reward Product: `%(reward_product)s`", + name=reward.program_id.name, + reward_product=product.name, + ) + if self.gift_card_program_id: + for product in self.gift_card_program_id.rule_ids.valid_product_ids: + if product.available_in_pos: + continue + invalid_reward_products_msg += "\n\t" + invalid_reward_products_msg += _( + "Program: %(name)s, Rule Product: `%(rule_product)s`", + name=reward.program_id.name, + rule_product=product.name, + ) + + if invalid_reward_products_msg: + prefix_error_msg = _("To continue, make the following reward products available in Point of Sale.") + raise UserError(f"{prefix_error_msg}\n{invalid_reward_products_msg}") + if self.use_gift_card and self.gift_card_program_id: + # Do not allow gift_card_program_id with more than one rule or reward, and check that they make sense + gc_program = self.gift_card_program_id + if len(gc_program.reward_ids) > 1: + raise UserError(_('Invalid gift card program. More than one reward.')) + elif len(gc_program.rule_ids) > 1: + raise UserError(_('Invalid gift card program. More than one rule.')) + rule = gc_program.rule_ids + if rule.reward_point_amount != 1 or rule.reward_point_mode != 'money': + raise UserError(_('Invalid gift card program rule. Use 1 point per currency spent.')) + reward = gc_program.reward_ids + if reward.reward_type != 'discount' or reward.discount_mode != 'per_point' or reward.discount != 1: + raise UserError(_('Invalid gift card program reward. Use 1 currency per point discount.')) + return super().open_session_cb() + + def use_coupon_code(self, code, creation_date, partner_id): + self.ensure_one() + # Ordering by partner id to use the first assigned to the partner in case multiple coupons have the same code + # it could happen with loyalty programs using a code + # Points desc so that in coupon mode one could use a coupon multiple times + coupon = self.env['loyalty.card'].search( + [('program_id', 'in', self.all_program_ids.ids), ('partner_id', 'in', (False, partner_id)), ('code', '=', code)], + order='partner_id, points desc', limit=1) + if not coupon or not coupon.program_id.active: + return { + 'successful': False, + 'payload': { + 'error_message': _('This coupon is invalid (%s).', code), + }, + } + check_date = fields.Date.from_string(creation_date[:11]) + if (coupon.expiration_date and coupon.expiration_date < check_date) or\ + (coupon.program_id.date_to and coupon.program_id.date_to <= fields.Date.context_today(self)) or\ + (coupon.program_id.limit_usage and coupon.program_id.total_order_count >= coupon.program_id.max_usage): + return { + 'successful': False, + 'payload': { + 'error_message': _('This coupon is expired (%s).', code), + }, + } + if not coupon.program_id.reward_ids or not any(reward.required_points <= coupon.points for reward in coupon.program_id.reward_ids): + return { + 'successful': False, + 'payload': { + 'error_message': _('No reward can be claimed with this coupon.'), + }, + } + return { + 'successful': True, + 'payload': { + 'program_id': coupon.program_id.id, + 'coupon_id': coupon.id, + 'coupon_partner_id': coupon.partner_id.id, + 'points': coupon.points, + }, + } diff --git a/addons/pos_loyalty/models/pos_order.py b/addons/pos_loyalty/models/pos_order.py new file mode 100644 index 00000000000..c6dd256701a --- /dev/null +++ b/addons/pos_loyalty/models/pos_order.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict +from odoo import _, models + +class PosOrder(models.Model): + _inherit = 'pos.order' + + def validate_coupon_programs(self, point_changes, new_codes): + """ + This is called upon validating the order in the pos. + + This will check the balance for any pre-existing coupon to make sure that the rewards are in fact all claimable. + This will also check that any set code for coupons do not exist in the database. + """ + point_changes = {int(k): v for k, v in point_changes.items()} + coupon_ids_from_pos = set(point_changes.keys()) + coupons = self.env['loyalty.card'].browse(coupon_ids_from_pos).exists().filtered('program_id.active') + coupon_difference = set(coupons.ids) ^ coupon_ids_from_pos + if coupon_difference: + return { + 'successful': False, + 'payload': { + 'message': _('Some coupons are invalid. The applied coupons have been updated. Please check the order.'), + 'removed_coupons': list(coupon_difference), + } + } + for coupon in coupons: + if (coupon.points + point_changes[coupon.id]) < 0: + return { + 'successful': False, + 'payload': { + 'message': _('There are not enough points for the coupon: %s.', coupon.code), + 'updated_points': {c.id: c.points for c in coupons} + } + } + # Check existing coupons + coupons = self.env['loyalty.card'].search([('code', 'in', new_codes)]) + if coupons: + return { + 'successful': False, + 'payload': { + 'message': _('The following codes already exist in the database, perhaps they were already sold?\n%s', + ', '.join(coupons.mapped('code'))), + } + } + return { + 'successful': True, + 'payload': {}, + } + + def confirm_coupon_programs(self, coupon_data): + """ + This is called after the order is created. + + This will create all necessary coupons and link them to their line orders etc.. + + It will also return the points of all concerned coupons to be updated in the cache. + """ + # Keys are stringified when using rpc + coupon_data = {int(k): v for k, v in coupon_data.items()} + # Map negative id to newly created ids. + coupon_new_id_map = {k: k for k in coupon_data.keys() if k > 0} + + # Create the coupons that were awarded by the order. + coupons_to_create = {k: v for k, v in coupon_data.items() if k < 0} + coupon_create_vals = [{ + 'program_id': p['program_id'], + 'partner_id': p.get('partner_id', False), + 'code': p.get('barcode') or self.env['loyalty.card']._generate_code(), + 'points': 0, + 'source_pos_order_id': self.id, + } for p in coupons_to_create.values()] + # Pos users don't have the create permission + new_coupons = self.env['loyalty.card'].sudo().create(coupon_create_vals) + # Map the newly created coupons + for old_id, new_id in zip(coupons_to_create.keys(), new_coupons): + coupon_new_id_map[new_id.id] = old_id + + all_coupons = self.env['loyalty.card'].browse(coupon_new_id_map.keys()).exists() + lines_per_reward_code = defaultdict(lambda: self.env['pos.order.line']) + for line in self.lines: + if not line.reward_identifier_code: + continue + lines_per_reward_code[line.reward_identifier_code] |= line + for coupon in all_coupons: + if coupon.id in coupon_new_id_map: + # Coupon existed previously, update amount of points. + coupon.points += coupon_data[coupon_new_id_map[coupon.id]]['points'] + for reward_code in coupon_data[coupon_new_id_map[coupon.id]].get('line_codes', []): + lines_per_reward_code[reward_code].coupon_id = coupon + # Reports per program + report_per_program = {} + coupon_per_report = defaultdict(list) + for coupon in new_coupons: + if coupon.program_id not in report_per_program: + report_per_program[coupon.program_id] = coupon.program_id.communication_plan_ids.\ + filtered(lambda c: c.trigger == 'create').pos_report_print_id + for report in report_per_program[coupon.program_id]: + coupon_per_report[report.id].append(coupon.id) + return { + 'coupon_updates': [{ + 'old_id': coupon_new_id_map[coupon.id], + 'id': coupon.id, + 'points': coupon.points, + 'code': coupon.code, + 'program_id': coupon.program_id.id, + 'partner_id': coupon.partner_id.id, + } for coupon in all_coupons if coupon.program_id.is_nominative], + 'program_updates': [{ + 'program_id': program.id, + 'usages': program.total_order_count, + } for program in all_coupons.program_id], + 'new_coupon_info': [{ + 'program_name': coupon.program_id.name, + 'expiration_date': coupon.expiration_date, + 'code': coupon.code, + } for coupon in new_coupons if coupon.program_id.applies_on == 'future'], + 'coupon_report': coupon_per_report, + } + + def _get_fields_for_order_line(self): + fields = super(PosOrder, self)._get_fields_for_order_line() + fields.extend(['is_reward_line', 'reward_id', 'coupon_id', 'reward_identifier_code', 'points_cost']) + return fields diff --git a/addons/pos_loyalty/models/pos_order_line.py b/addons/pos_loyalty/models/pos_order_line.py new file mode 100644 index 00000000000..1895d31e15c --- /dev/null +++ b/addons/pos_loyalty/models/pos_order_line.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + +class PosOrderLine(models.Model): + _inherit = 'pos.order.line' + + is_reward_line = fields.Boolean( + help="Whether this line is part of a reward or not.") + reward_id = fields.Many2one( + 'loyalty.reward', "Reward", ondelete='restrict', + help="The reward associated with this line.") + coupon_id = fields.Many2one( + 'loyalty.card', "Coupon", ondelete='restrict', + help="The coupon used to claim that reward.") + reward_identifier_code = fields.Char(help=""" + Technical field used to link multiple reward lines from the same reward together. + """) + points_cost = fields.Float(help="How many point this reward cost on the coupon.") + + def _order_line_fields(self, line, session_id=None): + res = super()._order_line_fields(line, session_id) + # coupon_id may be negative in case of new coupons, they will be added after validating the order. + if 'coupon_id' in res[2] and res[2]['coupon_id'] < 1: + res[2].pop('coupon_id') + return res + + def _is_not_sellable_line(self): + return super().is_not_sellable_line() or self.reward_id diff --git a/addons/pos_loyalty/models/pos_session.py b/addons/pos_loyalty/models/pos_session.py new file mode 100644 index 00000000000..7744df6617c --- /dev/null +++ b/addons/pos_loyalty/models/pos_session.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models +from odoo.osv.expression import OR + +class PosSession(models.Model): + _inherit = 'pos.session' + + def _pos_ui_models_to_load(self): + result = super()._pos_ui_models_to_load() + if self.config_id.all_program_ids: + result += [ + 'loyalty.program', + 'loyalty.rule', + 'loyalty.reward', + ] + return result + + def _loader_params_loyalty_program(self): + return { + 'search_params': { + 'domain': [('id', 'in', self.config_id.all_program_ids.ids)], + 'fields': ['name', 'trigger', 'applies_on', 'program_type', 'date_to', + 'limit_usage', 'max_usage', 'is_nominative', 'portal_point_name'], + }, + } + + def _loader_params_loyalty_rule(self): + return { + 'search_params': { + 'domain': [('program_id', 'in', self.config_id.all_program_ids.ids)], + 'fields': ['program_id', 'valid_product_ids', 'any_product', 'currency_id', + 'reward_point_amount', 'reward_point_split', 'reward_point_mode', + 'minimum_qty', 'minimum_amount', 'minimum_amount_tax_mode', 'mode', 'code'], + } + } + + def _loader_params_loyalty_reward(self): + return { + 'search_params': { + 'domain': [('program_id', 'in', self.config_id.all_program_ids.ids)], + 'fields': ['description', 'program_id', 'reward_type', 'required_points', 'clear_wallet', 'currency_id', + 'discount', 'discount_mode', 'discount_applicability', 'all_discount_product_ids', 'is_global_discount', + 'discount_max_amount', 'discount_line_product_id', + 'multi_product', 'reward_product_ids', 'reward_product_qty', 'reward_product_uom_id'], + } + } + + def _get_pos_ui_loyalty_program(self, params): + return self.env['loyalty.program'].search_read(**params['search_params']) + + def _get_pos_ui_loyalty_rule(self, params): + return self.env['loyalty.rule'].search_read(**params['search_params']) + + def _get_pos_ui_loyalty_reward(self, params): + return self.env['loyalty.reward'].search_read(**params['search_params']) + + def _get_pos_ui_res_partner(self, params): + result = super()._get_pos_ui_res_partner(params) + # In order to make loyalty programs work offline we load the partner's point into + # a non-existant field 'loyalty_points'. + if self.config_id.loyalty_program_id: + # collect ids in a list, group by id and default points to 0 + partner_ids = [] + res_by_id = {} + for res in result: + partner_ids.append(res['id']) + res_by_id[res['id']] = res + res['loyalty_points'] = 0 + res['loyalty_card_id'] = False + # Direct query to avoid loading loyalty cards in the cache for no reason. + # There is no context where we would need to flush. + query = self.env['loyalty.card']._search( + [('program_id', '=', self.config_id.loyalty_program_id.id), ('partner_id', 'in', partner_ids)] + ) + query_str, params = query.select('id', 'partner_id', 'points') + self.env.cr.execute(query_str, params) + for res in self.env.cr.dictfetchall(): + # The result of where_calc also includes partner_id is null. + if not res.get('partner_id'): + continue + res_by_id[res['partner_id']]['loyalty_points'] = res['points'] + res_by_id[res['partner_id']]['loyalty_card_id'] = res['id'] + return result + + def _loader_params_product_product(self): + result = super(PosSession, self)._loader_params_product_product() + config = self.config_id + if config.all_program_ids: + programs = config.all_program_ids + rewards = programs.reward_ids + products = (programs.rule_ids.valid_product_ids | rewards.discount_line_product_id) |\ + (rewards.all_discount_product_ids | rewards.reward_product_ids) + result['search_params']['domain'] = OR([result['search_params']['domain'], [('id', 'in', products.ids)]]) + return result diff --git a/addons/pos_loyalty/security/ir.model.access.csv b/addons/pos_loyalty/security/ir.model.access.csv new file mode 100644 index 00000000000..97e0ddb58ec --- /dev/null +++ b/addons/pos_loyalty/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_program_pos_user,Loyalty Program (PoS User),loyalty.model_loyalty_program,point_of_sale.group_pos_user,1,0,0,0 +access_program_pos_manager,Loyalty Program (PoS Manager),loyalty.model_loyalty_program,point_of_sale.group_pos_manager,1,1,1,1 +access_applicability_pos_user,Loyalty Rule (PoS User),loyalty.model_loyalty_rule,point_of_sale.group_pos_user,1,0,0,0 +access_applicability_pos_manager,Loyalty Rule (PoS Manager),loyalty.model_loyalty_rule,point_of_sale.group_pos_manager,1,1,1,1 +access_coupon_pos_user,Loyalty (PoS User),loyalty.model_loyalty_card,point_of_sale.group_pos_user,1,1,0,0 +access_coupon_pos_manager,Loyalty (PoS Manager),loyalty.model_loyalty_card,point_of_sale.group_pos_manager,1,1,1,0 +access_reward_pos_user,Loyalty Reward (PoS User),loyalty.model_loyalty_reward,point_of_sale.group_pos_user,1,0,0,0 +access_reward_pos_manager,Loyalty Reward (PoS Manager),loyalty.model_loyalty_reward,point_of_sale.group_pos_manager,1,1,1,1 +access_communication_pos_user,Loyalty Communication (PoS User),loyalty.model_loyalty_mail,point_of_sale.group_pos_user,1,0,0,0 +access_communication_pos_manager,Loyalty Communication (PoS Manager),loyalty.model_loyalty_mail,point_of_sale.group_pos_manager,1,1,1,1 +access_sale_coupon_generate,Coupon Generation,loyalty.model_loyalty_generate_wizard,point_of_sale.group_pos_user,1,1,1,0 diff --git a/addons/pos_loyalty/static/img/simple_pen.png b/addons/pos_loyalty/static/img/simple_pen.png new file mode 100644 index 00000000000..2ca4cd6626e Binary files /dev/null and b/addons/pos_loyalty/static/img/simple_pen.png differ diff --git a/addons/pos_loyalty/static/src/css/Loyalty.scss b/addons/pos_loyalty/static/src/css/Loyalty.scss new file mode 100644 index 00000000000..ab6ab1a5549 --- /dev/null +++ b/addons/pos_loyalty/static/src/css/Loyalty.scss @@ -0,0 +1,131 @@ +.pos .popups .gift-card-popup { + display: flex; + flex-wrap: wrap; + justify-content: space-around; + text-align: center; + margin: 1em; + + .gift-card-container { + display: flex; + padding: 5px; + font-weight: bold; + text-align: center; + width: 100%; + flex-direction: column; + align-items: center; + justify-content: space-between; + } + + .gift-card-footer { + text-align: center; + } + + .gift-card-input-amount { + border-bottom: 1px solid; + background-color: #F0EEEE; + box-shadow: none; + text-align: right; + width: 80%; + } + + .gift-card-input-code { + border-bottom: 1px solid; + background-color: #F0EEEE; + box-shadow: none; + text-align: center; + width: 80%; + } + + .gift-card-button { + width: auto !important; + height: auto !important; + padding: 10px; + } + + .gift-card-button-confirm { + flex-grow: 1; + height: auto !important; + padding: 10px; + } + + .gift-card-input-container { + width: 80%; + } + + .footer .gift-card-footer-button { + margin-left: 5px; + width: 70px; + } + + .gift-card-error { + width: 100%; + background-color: rgba(255, 76, 76, 0.5); + } +} + +.pos .order { + + .active-programs { + padding-top: 0.5em; + font-weight: normal; + font-size: 80%; + text-align: right; + margin-right: 15px; + margin-left: 15px; + + .title { + padding-top: 0.8em; + padding-bottom: 0.2em; + font-weight: bold; + } + } + + .orderline.program-reward { + font-style: italic; + } + + .summary { + .loyalty-points { + margin-left: 20px; + float: left; + padding: 10px; + max-width: 216px; + text-align: left; + color: #6EC89B; + background: rgba(110, 200, 155, 0.17); + border-radius: 3px; + } + + .loyalty-points-spent { + color: #C86E6E; + background: rgba(200, 110, 110, 0.17); + } + + .loyalty-points-total { + border-top: solid 2px; + text-align: center; + padding-top: 4px; + margin-top: 4px; + } + } +} + +.pos-receipt .pos-coupon-rewards { + text-align: center; + padding: 1em; +} + +.pos-coupon-rewards .coupon-container { + padding-top: 1em; + font-size: 75%; +} + +// Seems unused +.loyalty .subtitle{ + text-align: center; +} + +.loyalty .title { + font-size: 20px; + text-align: center; +} diff --git a/addons/pos_loyalty/static/src/js/ControlButtons/GiftCardButton.js b/addons/pos_loyalty/static/src/js/ControlButtons/GiftCardButton.js new file mode 100644 index 00000000000..d5a3b181ff5 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/ControlButtons/GiftCardButton.js @@ -0,0 +1,28 @@ +/** @odoo-module **/ + +import PosComponent from 'point_of_sale.PosComponent'; +import ProductScreen from 'point_of_sale.ProductScreen'; +import Registries from 'point_of_sale.Registries'; +import { useListener } from "@web/core/utils/hooks"; + +export class GiftCardButton extends PosComponent { + setup() { + super.setup() + useListener('click', this.onClick); + } + + async onClick() { + this.showPopup('GiftCardPopup', {}); + } +} + +GiftCardButton.template = 'GiftCardButton'; + +ProductScreen.addControlButton({ + component: GiftCardButton, + condition: function () { + return this.env.pos.config.use_gift_card; + }, +}); + +Registries.Component.add(GiftCardButton); diff --git a/addons/pos_loyalty/static/src/js/ControlButtons/PromoCodeButton.js b/addons/pos_loyalty/static/src/js/ControlButtons/PromoCodeButton.js new file mode 100644 index 00000000000..af1a8e9fa54 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/ControlButtons/PromoCodeButton.js @@ -0,0 +1,34 @@ +/** @odoo-module **/ + +import PosComponent from 'point_of_sale.PosComponent'; +import ProductScreen from 'point_of_sale.ProductScreen'; +import Registries from 'point_of_sale.Registries'; +import { useListener } from "@web/core/utils/hooks"; + +export class PromoCodeButton extends PosComponent { + setup() { + super.setup(); + useListener('click', this.onClick); + } + + async onClick() { + const { confirmed, payload: code } = await this.showPopup('TextInputPopup', { + title: this.env._t('Enter Promotion or Coupon Code'), + startingValue: '', + }); + if (confirmed && code !== '') { + this.env.pos.get_order().activateCode(code); + } + } +} + +PromoCodeButton.template = 'PromoCodeButton'; + +ProductScreen.addControlButton({ + component: PromoCodeButton, + condition: function () { + return this.env.pos.config.use_coupon_programs; + } +}); + +Registries.Component.add(PromoCodeButton); diff --git a/addons/pos_loyalty/static/src/js/ControlButtons/ResetProgramsButton.js b/addons/pos_loyalty/static/src/js/ControlButtons/ResetProgramsButton.js new file mode 100644 index 00000000000..348dd5ba6ca --- /dev/null +++ b/addons/pos_loyalty/static/src/js/ControlButtons/ResetProgramsButton.js @@ -0,0 +1,28 @@ +/** @odoo-module **/ + +import PosComponent from 'point_of_sale.PosComponent'; +import ProductScreen from 'point_of_sale.ProductScreen'; +import Registries from 'point_of_sale.Registries'; +import { useListener } from "@web/core/utils/hooks"; + +export class ResetProgramsButton extends PosComponent { + setup() { + super.setup(); + useListener('click', this.onClick); + } + + async onClick() { + this.env.pos.get_order()._resetPrograms(); + } +} + +ResetProgramsButton.template = 'ResetProgramsButton'; + +ProductScreen.addControlButton({ + component: ResetProgramsButton, + condition: function () { + return this.env.pos.config.use_coupon_programs; + } +}); + +Registries.Component.add(ResetProgramsButton); diff --git a/addons/pos_loyalty/static/src/js/ControlButtons/RewardButton.js b/addons/pos_loyalty/static/src/js/ControlButtons/RewardButton.js new file mode 100644 index 00000000000..a4c2e685704 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/ControlButtons/RewardButton.js @@ -0,0 +1,91 @@ +/** @odoo-module **/ + +import { Gui } from 'point_of_sale.Gui'; +import PosComponent from 'point_of_sale.PosComponent'; +import ProductScreen from 'point_of_sale.ProductScreen'; +import Registries from 'point_of_sale.Registries'; +import { useListener } from "@web/core/utils/hooks"; + +export class RewardButton extends PosComponent { + setup() { + super.setup() + useListener('click', this.onClick); + } + + hasClaimableRewards() { + const order = this.env.pos.get_order(); + const rewards = order ? order.getClaimableRewards() : []; + return rewards.length > 0; + } + + /** + * Applies the reward on the current order, if multiple products can be claimed opens a popup asking for which one. + * + * @param {Object} reward + * @param {Integer} coupon_id + */ + async _applyReward(reward, coupon_id) { + const args = {}; + if (reward.reward_type === 'product' && reward.multi_product) { + const productsList = reward.reward_product_ids.map((product_id) => ({ + id: product_id, + label: this.env.pos.db.get_product_by_id(product_id).display_name, + item: product_id, + })); + const { confirmed, payload: selectedProduct } = await this.showPopup('SelectionPopup', { + title: this.env._t('Please select a product for this reward'), + list: productsList, + }); + if (!confirmed) { + return false; + } + args['product'] = selectedProduct; + } + const result = this.env.pos.get_order()._applyReward(reward, coupon_id, args); + if (result !== true) { + // Returned an error + Gui.showNotification(result); + } + this.env.pos.get_order()._updateRewards(); + return result; + } + + async onClick() { + const order = this.env.pos.get_order(); + const rewards = order.getClaimableRewards(); + if (rewards.length === 0) { + await this.showPopup('ErrorPopup', { + title: this.env._t('No rewards available.'), + body: this.env._t('There are no rewards claimable for this customer.') + }); + return false; + } else if (rewards.length === 1) { + return this._applyReward(rewards[0].reward, rewards[0].coupon_id); + } else { + const rewardsList = rewards.map((reward) => ({ + id: reward.reward.id, + label: reward.reward.description, + item: reward, + })); + const { confirmed, payload: selectedReward } = await this.showPopup('SelectionPopup', { + title: this.env._t('Please select a reward'), + list: rewardsList, + }); + if (confirmed) { + return this._applyReward(selectedReward.reward, selectedReward.coupon_id); + } + } + return false; + } +} + +RewardButton.template = 'RewardButton'; + +ProductScreen.addControlButton({ + component: RewardButton, + condition: function() { + return this.env.pos.config.use_coupon_programs || this.env.pos.config.loyalty_program_id || this.env.pos.config.use_gift_card; + } +}); + +Registries.Component.add(RewardButton); diff --git a/addons/pos_loyalty/static/src/js/Loyalty.js b/addons/pos_loyalty/static/src/js/Loyalty.js new file mode 100644 index 00000000000..a29f5787e80 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/Loyalty.js @@ -0,0 +1,1311 @@ +/** @odoo-module **/ + +import { Order, Orderline, PosGlobalState} from 'point_of_sale.models'; +import Registries from 'point_of_sale.Registries'; +import session from 'web.session'; +import concurrency from 'web.concurrency'; +import { Gui } from 'point_of_sale.Gui'; +import { round_precision } from 'web.utils'; +import core from 'web.core'; + +const _t = core._t; +const dropPrevious = new concurrency.MutexedDropPrevious(); // Used for queuing reward updates +const mutex = new concurrency.Mutex(); // Used for sequential cache updates + +const COUPON_CACHE_MAX_SIZE = 4096 // Maximum coupon cache size, prevents long run memory issues and (to some extent) invalid data + +function _newRandomRewardCode() { + return (Math.random() + 1).toString(36).substring(3); +} + +let nextId = -1; +export class PosLoyaltyCard { + /** + * @param {string} code coupon code + * @param {number} id id of loyalty.card, negative if it is cache local only + * @param {number} program_id id of loyalty.program + * @param {number} partner_id id of res.partner + * @param {number} balance points on the coupon, not counting the order's changes + */ + constructor(code, id, program_id, partner_id, balance) { + this.code = code; + this.id = id || nextId--; + this.program_id = program_id; + this.partner_id = partner_id; + this.balance = balance; + } +} + +const PosLoyaltyGlobalState = (PosGlobalState) => class PosLoyaltyGlobalState extends PosGlobalState { + async _processData(loadedData) { + await super._processData(loadedData); + this.programs = loadedData['loyalty.program'] || []; //TODO: rename to `loyaltyPrograms` etc + this.rules = loadedData['loyalty.rule'] || []; + this.rewards = loadedData['loyalty.reward'] || []; + this.couponCache = {}; + this._loadLoyaltyData(); + } + _loadLoyaltyData() { + this.program_by_id = {}; + this.reward_by_id = {}; + + for (const program of this.programs) { + this.program_by_id[program.id] = program; + if (program.date_to) { + program.date_to = new Date(program.date_to); + } + program.rules = []; + program.rewards = []; + } + for (const rule of this.rules) { + rule.valid_product_ids = new Set(rule.valid_product_ids); + rule.program_id = this.program_by_id[rule.program_id[0]]; + rule.program_id.rules.push(rule); + } + for (const reward of this.rewards) { + this.reward_by_id[reward.id] = reward + reward.program_id = this.program_by_id[reward.program_id[0]];; + reward.discount_line_product_id = this.db.get_product_by_id(reward.discount_line_product_id[0]); + reward.all_discount_product_ids = new Set(reward.all_discount_product_ids); + reward.program_id.rewards.push(reward); + } + } + async load_server_data() { + await super.load_server_data(...arguments); + if (this.selectedOrder) { + this.selectedOrder._updateRewards(); + } + } + set_order(order) { + const result = super.set_order(...arguments); + // FIXME - JCB: This is a temporary fix. + // When an order is selected, it doesn't always contain the reward lines. + // And the list of active programs are not always correct. This is because + // of the use of DropPrevious in _updateRewards. + if (order) { + order._updateRewards(); + } + return result; + } + /** + * Fetches `loyalty.card` records from the server and adds/updates them in our cache. + * + * @param {domain} domain For the search + * @param {int} limit Default to 1 + */ + async fetchCoupons(domain, limit=1) { + const result = await this.env.services.rpc({ + model: 'loyalty.card', + method: 'search_read', + kwargs: { + domain: domain, + fields: ['id', 'points', 'code', 'partner_id', 'program_id'], + limit: limit, + context: session.user_context, + } + }); + if (Object.keys(this.couponCache).length + result.length > COUPON_CACHE_MAX_SIZE) { + this.couponCache = {}; + // Make sure that the current order has no invalid data. + if (this.selectedOrder) { + this.selectedOrder.invalidCoupons = true; + } + } + const couponList = []; + for (const dbCoupon of result) { + const coupon = new PosLoyaltyCard(dbCoupon.code, dbCoupon.id, dbCoupon.program_id[0], dbCoupon.partner_id[0], dbCoupon.points); + this.couponCache[coupon.id] = coupon; + couponList.push(coupon); + } + return couponList; + } + /** + * Fetches a loyalty card for the given program and partner, put in cache afterwards + * if a matching card is found in the cache, that one is used instead. + * If no card is found a local only card will be created until the order is validated. + * + * @param {int} programId + * @param {int} partnerId + */ + async fetchLoyaltyCard(programId, partnerId) { + if (programId === this.config.loyalty_program_id[0]) { + // In this case we actually have it loaded separately to avoid needing to be online + // for the loyalty program functionalities + // see `_get_pos_ui_res_partner` in pos_session.py + const partner = this.db.get_partner_by_id(partnerId); + return new PosLoyaltyCard(null, partner.loyalty_card_id, programId, partnerId, partner.loyalty_points); + } + for (const coupon of Object.values(this.couponCache)) { + if (coupon.partner_id === partnerId && coupon.program_id === programId) { + return coupon; + } + } + const dbCoupon = await this.fetchCoupons([['partner_id', '=', partnerId], ['program_id', '=', programId]])[0]; + return dbCoupon || new PosLoyaltyCard(null, null, programId, partnerId, 0); + } +} +Registries.Model.extend(PosGlobalState, PosLoyaltyGlobalState); + +const PosLoyaltyOrderline = (Orderline) => class PosLoyaltyOrderline extends Orderline { + export_as_JSON() { + const result = super.export_as_JSON(...arguments); + result.is_reward_line = this.is_reward_line; + result.reward_id = this.reward_id; + result.reward_product_id = this.reward_product_id; + result.coupon_id = this.coupon_id; + result.reward_identifier_code = this.reward_identifier_code; + result.points_cost = this.points_cost; + result.giftBarcode = this.giftBarcode; + return result; + } + init_from_JSON(json) { + if (json.is_reward_line) { + this.is_reward_line = json.is_reward_line; + this.reward_id = json.reward_id; + this.reward_product_id = json.reward_product_id; + // Since non existing coupon have a negative id, of which the counter is lost upon reloading + // we make sure that they are kept the same between after a reload between the order and the lines. + this.coupon_id = this.order.oldCouponMapping[json.coupon_id] || json.coupon_id; + this.reward_identifier_code = json.reward_identifier_code; + this.points_cost = json.points_cost; + this.giftBarcode = json.giftBarcode; + } + super.init_from_JSON(...arguments); + } + set_quantity(quantity, keep_price) { + if (quantity === 'remove' && this.is_reward_line) { + // Remove any line that is part of that same reward aswell. + const linesToRemove = [] + this.order.get_orderlines().forEach((line) => { + if (line != this && + line.reward_id === this.reward_id && + line.coupon_id === this.coupon_id && + line.reward_identifier_code === this.reward_identifier_code) { + linesToRemove.push(line); + } + }); + for (const line of linesToRemove) { + this.order.orderlines.remove(line); + } + } + return super.set_quantity(...arguments); + } +} +Registries.Model.extend(Orderline, PosLoyaltyOrderline); + +const PosLoyaltyOrder = (Order) => class PosLoyaltyOrder extends Order { + constructor() { + super(...arguments); + this._initializePrograms({}); + } + export_as_JSON() { + const json = super.export_as_JSON(...arguments); + json.disabledRewards = this.disabledRewards; + json.codeActivatedProgramRules = this.codeActivatedProgramRules; + json.codeActivatedCoupons = this.codeActivatedCoupons; + json.couponPointChanges = this.couponPointChanges; + return json; + } + init_from_JSON(json) { + this.couponPointChanges = json.couponPointChanges; + // Remapping of coupon_id for both couponPointChanges and Orderline.coupon_id + this.oldCouponMapping = {}; + if (this.couponPointChanges) { + for (const pe of Object.values(this.couponPointChanges)) { + if (pe.coupon_id > 0) { + continue; + } + const newId = nextId--; + delete this.oldCouponMapping[pe.coupon_id]; + pe.coupon_id = newId; + this.couponPointChanges[newId] = pe; + } + } + super.init_from_JSON(...arguments); + delete this.oldCouponMapping; + this.disabledRewards = json.disabledRewards; + this.codeActivatedProgramRules = json.codeActivatedProgramRules; + this.codeActivatedCoupons = json.codeActivatedCoupons; + this.invalidCoupons = true; + } + /** + * We need to update the rewards upon changing the partner as it may impact the points available + * for rewards. + * + * @override + */ + set_partner(partner) { + const oldPartner = this.get_partner(); + super.set_partner(...arguments); + if (oldPartner !== this.get_partner()) { + this._updateRewards(); + } + } + wait_for_push_order() { + return (!_.isEmpty(this.couponPointChanges) || this._get_reward_lines().length || super.wait_for_push_order(...arguments)); + } + /** + * Add additional information for our ticket, such as new coupons and loyalty point gains. + * + * @override + */ + export_for_printing() { + const result = super.export_for_printing(...arguments); + if (this.pos.config.loyalty_program_id && this.get_partner()) { + const loyaltyProgram = this.pos.program_by_id[this.pos.config.loyalty_program_id[0]]; + result.loyalty = { + name: loyaltyProgram.name, + point_name: loyaltyProgram.portal_point_name, + partner_name: this.get_partner().name, + newPoints: this.getLoyaltyPoints(), + }; + } + result.new_coupon_info = this.new_coupon_info; + return result; + } + /** + * Apply changes to loyalty program points directly to support offline loyalty. + * + * @override + */ + finalize() { + const partner = this.get_partner(); + const loyaltyProgramId = this.pos.config.loyalty_program_id ? this.pos.config.loyalty_program_id[0]: 0; + if (partner && loyaltyProgramId) { + for (const pe of Object.values(this.couponPointChanges)) { + if (pe.program_id === loyaltyProgramId) { + partner.loyalty_points += pe.points; + break; + } + } + for (const line of this.get_orderlines()) { + if (line.reward_id) { + const reward = this.pos.reward_by_id[line.reward_id]; + if (reward.program_id.id !== loyaltyProgramId) { + continue; + } + partner.loyalty_points -= line.points_cost; + } + } + } + super.finalize(...arguments); + } + + get_orderlines() { + const orderlines = super.get_orderlines(this, arguments); + const rewardLines = []; + const nonRewardLines = []; + for (const line of orderlines) { + if (line.is_reward_line) { + rewardLines.push(line); + } else { + nonRewardLines.push(line); + } + } + return [...nonRewardLines, ...rewardLines]; + } + _get_reward_lines() { + const orderLines = super.get_orderlines(...arguments); + if (orderLines) { + return orderLines.filter((line) => line.is_reward_line); + } + return orderLines; + } + _get_regular_order_lines() { + const orderLines = super.get_orderlines(...arguments); + if (orderLines) { + return orderLines.filter((line) => !line.is_reward_line && !line.refunded_orderline_id); + } + return orderLines; + } + get_last_orderline() { + const orderLines = super.get_orderlines(...arguments).filter((line) => !line.is_reward_line); + return orderLines[orderLines.length - 1]; + } + set_orderline_options(line, options) { + super.set_orderline_options(...arguments); + if (options && options.is_reward_line) { + line.is_reward_line = options.is_reward_line; + line.reward_id = options.reward_id; + line.reward_product_id = options.reward_product_id; + line.coupon_id = options.coupon_id; + line.reward_identifier_code = options.reward_identifier_code; + line.points_cost = options.points_cost; + line.price_manually_set = true; + } + line.giftBarcode = options.giftBarcode; + } + add_product(product, options) { + super.add_product(...arguments); + this._updateRewards(); + } + + async _initializePrograms() { + // When deleting a reward line, a popup will be displayed if the reward was automatic, + // if confirmed the reward is added to this list and will not be claimed automatically again. + if (!this.disabledRewards) { + this.disabledRewards = []; + } + // List of programs that require a code that are activated. + if (!this.codeActivatedProgramRules) { + this.codeActivatedProgramRules = []; + } + // List of coupons activated manually + if (!this.codeActivatedCoupons) { + this.codeActivatedCoupons = []; + } + // This field will hold the added points for each coupon. + // Points lost are directly linked to the order lines. + if (!this.couponPointChanges) { + this.couponPointChanges = {}; + } + } + _resetPrograms() { + this.disabledRewards = []; + this.codeActivatedProgramRules = []; + this.codeActivatedCoupons = []; + this.couponPointChanges = {}; + this.orderlines.remove(this._get_reward_lines()); + this._updateRewards(); + } + _updateRewards() { + // Calls are not expected to take some time besides on the first load + when loyalty programs are made applicable + if (this.pos.programs.length === 0) { + return; + } + dropPrevious.exec(() => {return this._updateLoyaltyPrograms().then(async () => { + // Try auto claiming rewards + const claimableRewards = this.getClaimableRewards(false, false, true); + let changed = false; + for (const {coupon_id, reward} of claimableRewards) { + if (reward.program_id.rewards.length === 1 && !reward.program_id.is_nominative && + (reward.reward_type !== 'product' || !reward.multi_product)) { + this._applyReward(reward, coupon_id); + changed = true; + } + } + // Rewards may impact the number of points gained + if (changed) { + await this._updateLoyaltyPrograms(); + } + this._updateRewardLines(); + })}).catch(() => {/* catch the reject of dp when calling `add` to avoid unhandledrejection */}); + } + async _updateLoyaltyPrograms() { + await this._checkMissingCoupons(); + await this._updatePrograms(); + } + /** + * Checks that all 'existing' coupons are in our cache, and if not load/update them. + */ + async _checkMissingCoupons() { + // This function must stay sequential to avoid potential concurrency errors. + await mutex.exec(async () => { + if (!this.invalidCoupons) { + return; + } + this.invalidCoupons = false; + const allCoupons = []; + for (const pe of Object.values(this.couponPointChanges)) { + if (pe.coupon_id > 0) { + allCoupons.push(pe.coupon_id); + } + } + allCoupons.push(...this.codeActivatedCoupons.map((coupon) => coupon.id)); + const couponsToFetch = allCoupons.filter((elem) => !this.pos.couponCache[elem]); + if (couponsToFetch.length) { + await this.pos.fetchCoupons([['id', 'in', couponsToFetch]], couponsToFetch.length); + // Remove coupons that could not be loaded from the db + this.codeActivatedCoupons = this.codeActivatedCoupons.filter((coupon) => this.pos.couponCache[coupon.id]); + this.couponPointChanges = Object.fromEntries(Object.entries(this.couponPointChanges).filter((k, pe) => this.pos.couponCache[pe.coupon_id])); + } + }); + } + /** + * Refreshes the currently applied rewards, if they are not applicable anymore they are removed. + */ + _updateRewardLines() { + if (!this.orderlines.length) { + return; + } + const rewardLines = this._get_reward_lines(); + if (!rewardLines.length) { + return; + } + const claimedRewards = []; + const paymentRewards = []; // Gift card and ewallet rewards are considered payments and must stay at the end + for (const line of rewardLines) { + const claimedReward = { + reward: this.pos.reward_by_id[line.reward_id], + coupon_id: line.coupon_id, + args: { + product: line.reward_product_id, + } + } + if (claimedReward.reward.program_id.program_type === 'gift_card' || claimedReward.reward.program_id.program_type === 'ewallet') { + paymentRewards.push(claimedReward); + } else if (claimedReward.reward.reward_type === 'product') { + claimedRewards.unshift(claimedReward); + } else { + claimedRewards.push(claimedReward); + } + this.orderlines.remove(line); + } + claimedRewards.push(...paymentRewards); + for (const claimedReward of claimedRewards) { + // For existing coupons check that they are still claimed, they can exist in either `couponPointChanges` or `codeActivatedCoupons` + if (!this.codeActivatedCoupons.find((coupon) => coupon.id === claimedReward.coupon_id) && + !this.couponPointChanges[claimedReward.coupon_id]) { + continue; + } + this._applyReward(claimedReward.reward, claimedReward.coupon_id, claimedReward.args); + } + } + /** + * Update our couponPointChanges, meaning the points/coupons each program give etc. + */ + async _updatePrograms() { + const changesPerProgram = {}; + const programsToCheck = new Set(); + // By default include all programs that are considered 'applicable' + for (const program of this.pos.programs) { + if (this._programIsApplicable(program)) { + programsToCheck.add(program.id); + } + } + for (const pe of Object.values(this.couponPointChanges)) { + if (!changesPerProgram[pe.program_id]) { + changesPerProgram[pe.program_id] = []; + programsToCheck.add(pe.program_id); + } + changesPerProgram[pe.program_id].push(pe); + } + for (const coupon of this.codeActivatedCoupons) { + programsToCheck.add(coupon.program_id); + } + const programs = [...programsToCheck].map((programId) => this.pos.program_by_id[programId]); + const pointsAddedPerProgram = this.pointsForPrograms(programs); + for (const program of this.pos.programs) { + // Future programs may split their points per unit paid (gift cards for example), consider a non applicable program to give no points + const pointsAdded = this._programIsApplicable(program) ? pointsAddedPerProgram[program.id] : []; + // For programs that apply to both (loyalty) we always add a change of 0 points, if there is none, since it makes it easier to + // track for claimable rewards, and makes sure to load the partner's loyalty card. + if (program.is_nominative && !pointsAdded.length && this.get_partner()) { + pointsAdded.push({points: 0}); + } + const oldChanges = changesPerProgram[program.id] || []; + // Update point changes for those that exist + for (let idx = 0; idx < Math.min(pointsAdded.length, oldChanges.length); idx++) { + Object.assign(oldChanges[idx], pointsAdded[idx]); + } + if (pointsAdded.length < oldChanges.length) { + const removedIds = oldChanges.map((pe) => pe.coupon_id); + this.couponPointChanges = Object.fromEntries(Object.entries(this.couponPointChanges).filter(([k, pe]) => { + return !removedIds.includes(pe.coupon_id); + })); + } else if (pointsAdded.length > oldChanges.length) { + for (const pa of pointsAdded.splice(oldChanges.length)) { + const coupon = await this._couponForProgram(program); + this.couponPointChanges[coupon.id] = { + points: pa.points, + program_id: program.id, + coupon_id: coupon.id, + barcode: pa.barcode, + }; + } + } + } + // Also remove coupons from codeActivatedCoupons if their program applies_on current orders and the program does not give any points + this.codeActivatedCoupons = this.codeActivatedCoupons.filter((coupon) => { + const program = this.pos.program_by_id[coupon.program_id]; + if (program.applies_on === 'current' && pointsAddedPerProgram[program.id].length === 0) { + return false; + } + return true; + }); + } + /** + * @returns {Object} containing the following keys: `won`, `spent`, `total` and `name` for the points name + */ + getLoyaltyPoints() { + let won = 0; + let spent = 0; + let total = 0; + const partner = this.get_partner(); + if (partner && this.pos.config.loyalty_program_id) { + let couponId = partner.loyalty_card_id; + let balance = partner.loyalty_points; + for (const pe of Object.values(this.couponPointChanges)) { + if (pe.program_id === this.pos.config.loyalty_program_id[0]) { + if (!couponId) { + couponId = pe.coupon_id; + } + won += pe.points; + break; + } + } + if (couponId !== 0) { + for (const line of this._get_reward_lines()) { + if (line.coupon_id === couponId) { + spent += line.points_cost; + } + } + } + total = balance + won - spent; + } + let name = _t('Points'); + if (this.pos.config.loyalty_program_id) { + name = this.pos.program_by_id[this.pos.config.loyalty_program_id[0]].portal_point_name; + } + return { + won: parseFloat(won.toFixed(2)), + spent: parseFloat(spent.toFixed(2)), + total: parseFloat(total.toFixed(2)), + name: name, + } + } + /** + * @returns {number} The points that are left for the given coupon for this order. + */ + _getRealCouponPoints(coupon_id) { + let points = 0; + const dbCoupon = this.pos.couponCache[coupon_id]; + if (dbCoupon) { + points += dbCoupon.balance; + } + Object.values(this.couponPointChanges).some((pe) => { + if (pe.coupon_id === coupon_id) { + if (this.pos.program_by_id[pe.program_id].applies_on !== 'future') { + points += pe.points; + } + // couponPointChanges is not supposed to have a coupon multiple times + return true; + } + return false + }); + for (const line of this.get_orderlines()) { + if (line.is_reward_line && line.coupon_id === coupon_id) { + points -= line.points_cost; + } + } + return points + } + /** + * Depending on the program type returns a new (local) instance of coupon or tries to retrieve the coupon in case of loyalty cards. + * Existing coupons are put in a cache which is also used to fetch the coupons. + * + * @param {object} program + */ + async _couponForProgram(program) { + if (program.is_nominative) { + return this.pos.fetchLoyaltyCard(program.id, this.get_partner().id); + } + // This type of coupons don't need to really exist up until validating the order, so no need to cache + return new PosLoyaltyCard(null, null, program.id, (this.get_partner() || {id: -1}).id, 0); + } + _programIsApplicable(program) { + if (program.trigger === 'auto' && !program.rules.find((rule) => rule.mode === 'auto' || this.codeActivatedProgramRules.includes(rule.id))) { + return false; + } + if (program.trigger === 'with_code' && !program.rules.find((rule) => this.codeActivatedProgramRules.includes(rule.id))) { + return false; + } + if (program.is_nominative && !this.get_partner()) { + return false; + } + if (program.date_to && program.date_to <= new Date()) { + return false; + } + if (program.limit_usage && program.total_order_count >= program.max_usage) { + return false; + } + return true; + } + /** + * Computes how much points each program gives. + * + * @param {Array} programs list of loyalty.program + * @returns {Object} Containing the points gained per program + */ + pointsForPrograms(programs) { + const totalTaxed = this.get_total_with_tax(); + const totalUntaxed = this.get_total_without_tax(); + const totalsPerProgram = Object.fromEntries(programs.map((program) => [program.id, {'untaxed': totalUntaxed, 'taxed': totalTaxed}])); + const orderLines = this.get_orderlines(); + for (const line of orderLines) { + if (!line.reward_id) { + continue; + } + const reward = this.pos.reward_by_id[line.reward_id]; + if (reward.reward_type !== 'discount') { + continue; + } + const rewardProgram = reward.program_id; + for (const program of programs) { + // Remove automatic discount and this program's discounts from the totals. + if (program.id === rewardProgram.id || rewardProgram.trigger === 'auto') { + totalsPerProgram[program.id]['taxed'] -= line.get_price_with_tax(); + totalsPerProgram[program.id]['untaxed'] -= line.get_price_without_tax(); + } + } + } + const result = {} + for (const program of programs) { + let points = 0; + const splitPoints = []; + for (const rule of program.rules) { + if (rule.mode === 'with_code' && !this.codeActivatedProgramRules.includes(rule.id)) { + continue; + } + const amountCheck = rule.minimum_amount_tax_mode === 'incl' && totalsPerProgram[program.id]['taxed'] || totalsPerProgram[program.id]['untaxed']; + if (rule.minimum_amount > amountCheck) { // NOTE: big doutes par rapport au fait de compter tous les produits + continue; + } + let totalProductQty = 0; + // Only count points for paid lines. + const qtyPerProduct = {}; + let orderedProductPaid = 0; + for (const line of orderLines) { + + if ((!line.reward_product_id && (rule.any_product || rule.valid_product_ids.has(line.get_product().id))) || + (line.reward_product_id && (rule.any_product || rule.valid_product_ids.has(line.reward_product_id)))) { + // We only count reward products from the same program to avoid unwanted feedback loops + if (line.reward_product_id) { + const reward = this.pos.reward_by_id[line.reward_id]; + if (program.id !== reward.program_id) { + continue; + } + } + const lineQty = (line.reward_product_id ? -line.get_quantity() : line.get_quantity()); + totalProductQty += lineQty; + if (qtyPerProduct[line.reward_product_id || line.get_product().id]) { + qtyPerProduct[line.reward_product_id || line.get_product().id] += lineQty; + } else { + qtyPerProduct[line.reward_product_id || line.get_product().id] = lineQty; + } + orderedProductPaid += line.get_price_with_tax(); + } + } + if (totalProductQty < rule.minimum_qty) { + continue; + } + if (program.applies_on === 'future' && rule.reward_point_split && rule.reward_point_mode !== 'order') { + // In this case we count the points per rule + if (rule.reward_point_mode === 'unit') { + splitPoints.push(...Array.apply(null, Array(totalProductQty)).map((_) => {return {points: rule.reward_point_amount}})); + } else if (rule.reward_point_mode === 'money') { + for (const line of orderLines) { + if (line.is_reward_line || !(rule.valid_product_ids.has(line.get_product().id)) || line.get_quantity() <= 0) { + continue; + } + const pointsPerUnit = round_precision(rule.reward_point_amount * line.get_price_with_tax() / line.get_quantity(), 0.01); + if (pointsPerUnit > 0) { + splitPoints.push(...Array.apply(null, Array(line.get_quantity())).map(() => { + if (line.giftBarcode && line.get_quantity() == 1) { + return {points: pointsPerUnit, barcode: line.giftBarcode}; + } + return {points: pointsPerUnit} + })); + } + } + } + } else { + // In this case we add on to the global point count + if (rule.reward_point_mode === 'order') { + points += rule.reward_point_amount; + } else if (rule.reward_point_mode === 'money') { + // NOTE: unlike in sale_loyalty this performs a round half-up instead of round down + points += round_precision(rule.reward_point_amount * orderedProductPaid, 0.01); + } else if (rule.reward_point_mode === 'unit') { + points += rule.reward_point_amount * totalProductQty; + } + } + } + const res = points ? [{points}] : []; + if (splitPoints.length) { + res.push(...splitPoints); + } + result[program.id] = res; + } + return result; + } + /** + * @returns {Array} List of lines composing the global discount + */ + _getGlobalDiscountLines() { + return this.get_orderlines().filter((line) => line.reward_id && this.pos.reward_by_id[line.reward_id].is_global_discount); + } + /** + * @param {Integer} coupon_id (optional) Coupon id + * @param {Integer} program_id (optional) Program id + * @returns {Array} List of {Object} containing the coupon_id and reward keys + */ + getClaimableRewards(coupon_id=false, program_id=false, auto=false) { + const allCouponPrograms = Object.values(this.couponPointChanges).map((pe) => { + return { + program_id: pe.program_id, + coupon_id: pe.coupon_id, + }; + }).concat(this.codeActivatedCoupons.map((coupon) => { + return { + program_id: coupon.program_id, + coupon_id: coupon.id, + }; + })); + const result = []; + const totalIsZero = this.get_total_with_tax() === 0; + const globalDiscountLines = this._getGlobalDiscountLines(); + const globalDiscountPercent = globalDiscountLines.length ? + this.pos.reward_by_id[globalDiscountLines[0].reward_id].discount : 0; + for (const couponProgram of allCouponPrograms) { + if ((coupon_id && couponProgram.coupon_id !== coupon_id) || + (program_id && couponProgram.program_id !== program_id)) { + continue; + } + const program = this.pos.program_by_id[couponProgram.program_id]; + const points = this._getRealCouponPoints(couponProgram.coupon_id); + for (const reward of program.rewards) { + if (points < reward.required_points) { + continue; + } + if (auto && this.disabledRewards.includes(reward.id)) { + continue; + } + // Try to filter out rewards that will not be claimable anyway. + if (reward.is_global_discount && reward.discount <= globalDiscountPercent) { + continue; + } + if (reward.reward_type === 'discount' && totalIsZero) { + continue; + } + if (reward.reward_type === 'product' && !reward.multi_product) { + const product = this.pos.db.get_product_by_id(reward.reward_product_ids[0]); + const availableQty = this._computeAvailableFreeProduct(reward, couponProgram.coupon_id, product); + if (availableQty <= 0) { + continue; + } + } + result.push({ + coupon_id: couponProgram.coupon_id, + reward: reward, + }); + } + } + return result; + } + /** + * Applies a reward to the order, `_updateRewards` is expected to be called right after. + * + * @param {loyalty.reward} reward + * @param {Integer} coupon_id + * @param {Object} args Reward options + * @returns True if everything went right or an error message + */ + _applyReward(reward, coupon_id, args) { + if (this._getRealCouponPoints(coupon_id) < reward.required_points) { + return _t("There are not enough points on the coupon to claim this reward."); + } + if (reward.is_global_discount) { + const globalDiscountLines = this._getGlobalDiscountLines(); + if (globalDiscountLines.length) { + const rewardId = globalDiscountLines[0].reward_id; + if (rewardId != reward.id && this.pos.reward_by_id[rewardId].discount >= reward.discount) { + return _t("A better global discount is already applied."); + } else if (rewardId != rewardId.id) { + for (const line of globalDiscountLines) { + this.orderlines.remove(line); + } + } + } + } + args = args || {}; + const rewardLines = this._getRewardLineValues({ + reward: reward, + coupon_id: coupon_id, + product: args['product'] || null, + }); + if (!Array.isArray(rewardLines)) { + return rewardLines; // Returned an error. + } + if (!rewardLines.length) { + return _t("The reward could not be applied."); + } + for (const rewardLine of rewardLines) { + this.orderlines.add(this._createLineFromVals(rewardLine)); + } + return true; + } + _createLineFromVals(vals) { + vals['lst_price'] = vals['price']; + const line = Orderline.create({}, {pos: this.pos, order: this, product: vals['product']}); + this.fix_tax_included_price(line); + this.set_orderline_options(line, vals); + return line; + } + /** + * @param {loyalty.reward} reward + * @returns the discountable and discountable per tax for this discount on order reward. + */ + _getDiscountableOnOrder(reward) { + let discountable = 0; + const discountablePerTax = {}; + for (const line of this.get_orderlines()) { + if (!line.get_quantity()) { + continue; + } + const taxKey = line.get_taxes().map((t) => t.id); + discountable += line.get_price_with_tax(); + if (!discountablePerTax[taxKey]) { + discountablePerTax[taxKey] = 0; + } + discountablePerTax[taxKey] += line.price * line.get_quantity(); + } + return {discountable, discountablePerTax}; + } + /** + * @returns the order's cheapest line + */ + _getCheapestLine() { + let cheapestLine; + for (const line of this.get_orderlines()) { + if (line.reward_id || !line.get_quantity()) { + continue; + } + if (!cheapestLine || cheapestLine.price > line.price) { + cheapestLine = line; + } + } + return cheapestLine; + } + /** + * @param {loyalty.reward} reward + * @returns the discountable and discountable per tax for this discount on cheapest reward. + */ + _getDiscountableOnCheapest(reward) { + const cheapestLine = this._getCheapestLine(); + if (!cheapestLine) { + return {discountable: 0, discountablePerTax: {}}; + } + const taxKey = cheapestLine.get_taxes().map((t) => t.id); + return {discountable: cheapestLine.price, discountablePerTax: Object.fromEntries([[taxKey, cheapestLine.price]])}; + } + /** + * @param {loyalty.reward} reward + * @returns all lines to which the reward applies. + */ + _getSpecificDiscountableLines(reward) { + const discountableLines = []; + const applicableProducts = reward.all_discount_product_ids; + for (const line of this.get_orderlines()) { + if (!line.get_quantity()) { + continue; + } + if (applicableProducts.has(line.get_product().id) || + applicableProducts.has(line.reward_product_id)) { + discountableLines.push(line); + } + } + return discountableLines; + } + /** + * For a 'specific' type of discount it is more complicated as we have to make sure that we never + * discount more than what is available on a per line basis. + * @param {loyalty.reward} reward + * @returns the discountable and discountable per tax for this discount on specific reward. + */ + _getDiscountableOnSpecific(reward) { + const applicableProducts = reward.all_discount_product_ids; + const linesToDiscount = []; + const discountLinesPerReward = {}; + const orderLines = this.get_orderlines(); + const remainingAmountPerLine = {}; + for (const line of orderLines) { + if (!line.get_quantity() || !line.price) { + continue; + } + remainingAmountPerLine[line.cid] = line.get_price_with_tax(); + if (applicableProducts.has(line.get_product().id) || + (line.reward_product_id && applicableProducts.has(line.reward_product_id))) { + linesToDiscount.push(line); + } else if (line.reward_id) { + const lineReward = this.pos.reward_by_id[line.reward_id]; + if (lineReward.id === reward.id) { + continue; + } + if (!discountLinesPerReward[line.reward_identifier_code]) { + discountLinesPerReward[line.reward_identifier_code] = []; + } + discountLinesPerReward[line.reward_identifier_code].push(line); + } + } + + let cheapestLine = false; + for (const lines of Object.values(discountLinesPerReward)) { + const lineReward = this.pos.reward_by_id[lines[0].reward_id]; + let discountedLines = orderLines; + if (lineReward.discount_applicability === 'cheapest') { + cheapestLine = cheapestLine || this._getCheapestLine(); + discountedLines = [cheapestLine]; + } else if (lineReward.discount_applicability === 'specific') { + discountedLines = this._getSpecificDiscountableLines(lineReward); + } + if (!discountedLines.length) { + continue; + } + const commonLines = linesToDiscount.filter((line) => discountedLines.includes(line)); + if (lineReward.discount_mode === 'percent') { + const discount = lineReward.discount / 100; + for (const line of discountedLines) { + if (line.reward_id) { + continue; + } + if (lineReward.discount_applicability === 'cheapest') { + remainingAmountPerLine[line.cid] *= (1 - (discount / line.get_quantity())) + } else { + remainingAmountPerLine[line.cid] *= (1 - discount); + } + } + } else { + const nonCommonLines = discountedLines.filter((line) => !linesToDiscount.includes(line)); + let discountedAmount = Math.abs(lines.reduce((sum, line) => sum + line.get_price_with_tax(), 0)); + const process = (line) => { + if (!discountedAmount || line.reward_id) { + return; + } + const remaining = remainingAmountPerLine[line.cid]; + const consumed = Math.min(remaining, discountedAmount); + discountedAmount -= consumed; + remainingAmountPerLine[line.cid] -= consumed; + } + nonCommonLines.forEach(process); + commonLines.forEach(process); + } + } + + let discountable = 0; + const discountablePerTax = {}; + for (const line of linesToDiscount) { + discountable += remainingAmountPerLine[line.cid]; + const taxKey = line.get_taxes().map((t) => t.id); + if (!discountablePerTax[taxKey]) { + discountablePerTax[taxKey] = 0; + } + discountablePerTax[taxKey] += (line.price * line.get_quantity()) * (remainingAmountPerLine[line.cid] / line.get_price_with_tax()); + } + return {discountable, discountablePerTax}; + } + /** + * @param {Object} args See `_applyReward` + * @returns {Array} List of values to create the reward lines + */ + _getRewardLineValues(args) { + const reward = args['reward']; + if (reward.reward_type === 'discount') { + return this._getRewardLineValuesDiscount(args); + } else if (reward.reward_type === 'product') { + return this._getRewardLineValuesProduct(args); + } + // NOTE: we may reach this step if for some reason there is a free shipping reward + return []; + } + /** + * @param {Object} args See `_applyReward` + * @returns {Array} List of values to create the discount lines + */ + _getRewardLineValuesDiscount(args) { + const reward = args['reward']; + const coupon_id = args['coupon_id']; + const rewardAppliesTo = reward.discount_applicability; + let getDiscountable; + if (rewardAppliesTo === 'order') { + getDiscountable = this._getDiscountableOnOrder.bind(this); + } else if (rewardAppliesTo === 'cheapest') { + getDiscountable = this._getDiscountableOnCheapest.bind(this); + } else if (rewardAppliesTo === 'specific') { + getDiscountable = this._getDiscountableOnSpecific.bind(this); + } + if (!getDiscountable) { + return _t("Unknown discount type"); + } + let {discountable, discountablePerTax} = getDiscountable(reward); + discountable = Math.min(this.get_total_with_tax(), discountable); + if (!discountable) { + return []; + } + let maxDiscount = reward.discount_max_amount || Infinity; + if (reward.discount_mode === 'per_point') { + maxDiscount = Math.min(maxDiscount, reward.discount * this._getRealCouponPoints(coupon_id)); + } else if (reward.discount_mode === 'per_order') { + maxDiscount = Math.min(maxDiscount, reward.discount); + } else if (reward.discount_mode === 'percent') { + maxDiscount = Math.min(maxDiscount, discountable * (reward.discount / 100)); + } + const rewardCode = _newRandomRewardCode(); + let pointCost = reward.clear_wallet ? this._getRealCouponPoints(coupon_id) : reward.required_points; + if (reward.discount_mode === 'per_point' && !reward.clear_wallet) { + pointCost = Math.min(maxDiscount, discountable) / reward.discount; + } + // These are considered payments and do not require to be either taxed or split by tax + if (['ewallet', 'gift_card'].includes(reward.program_id.program_type)) { + return [{ + product: discountProduct, + price: -Math.min(maxDiscount, discountable), + quantity: 1, + reward_id: reward.id, + is_reward_line: true, + coupon_id: coupon_id, + points_cost: pointCost, + reward_identifier_code: rewardCode, + merge: false, + }]; + } + const discountFactor = discountable ? Math.min(1, (maxDiscount / discountable)) : 1; + const discountProduct = reward.discount_line_product_id; + const result = Object.entries(discountablePerTax).reduce((lst, entry) => { + const taxIds = entry[0] === '' ? [] : entry[0].split(',').map((str) => parseInt(str)); + lst.push({ + product: discountProduct, + price: -(entry[1] * discountFactor), + quantity: 1, + reward_id: reward.id, + is_reward_line: true, + coupon_id: coupon_id, + points_cost: 0, + reward_identifier_code: rewardCode, + tax_ids: taxIds, + merge: false, + }); + return lst; + }, []); + if (result.length) { + result[0]['points_cost'] = pointCost; + } + return result; + } + /** + * Tries to compute how many free product can be given out for the given product. + * Contrary to sale_loyalty, the product must be in the order lines in order to give it out + * (resulting in discount lines for the product's value). + * As such we need to approximate the effect of removing 1 quantity on the counting of points in order + * to avoid feedback loops between giving a product and it removing the required points for it. + * + * @param {loyalty.reward} reward + * @param {Integer} coupon_id + * @param {Product} product + * @returns {Integer} Available quantity to be given as reward for the given product + */ + _computeAvailableFreeProduct(reward, coupon_id, product) { + // Might not be perfect but good enough + let productPriceWithTax = null; + const orderlines = this.get_orderlines(); + const program = reward.program_id; + let freeClaimed = 0; + let available = 0; + for (const line of orderlines) { + if (line.get_product().id === product.id) { + if (productPriceWithTax === null) { + productPriceWithTax = line.get_price_with_tax() / line.get_quantity(); + } + available += line.get_quantity(); + } else if (line.reward_product_id === product.id) { + freeClaimed += line.get_quantity(); + } + } + // This only applies to programs that apply on current and both orders and only if points are being counted for that coupon. + const pointChangeEntry = this.couponPointChanges[coupon_id]; + if (available && (pointChangeEntry && program.applies_on === 'current' || program.applies_on === 'both')) { + /** + * We have to count the points again in this case because we have to know how much points will be lost + * after we apply the free product, take for example a 2+1 free program, you could have 1 points per unit paid + * with the reward being the free product that takes 1 point. At the time of counting the points + * if you have 2 units in your list you would technically be eligible for the reward since you have enough points. + * However because claiming the unit would result in a loss of 2 points, you would lose our eligibility. + * This seams like a small problem but we have to take note that we may also get points from other products and as such they + * a free product might not impact the points at all. + * Example program: + * 2 rules: + * 1: 1 point per unit paid on product A. + * 2 10 points per unit paid on product B. + * 1 reward: + * 1: for 2 point, 1 free product A. + * This means that if we have a product B we are eligible for 5 free product A. + * But also that we need at least 6 product A before rule 1 starts counting points again. + * Since reward lines at this point may or may not be present we have to remove the points we compute in this function. + */ + let points = this._getRealCouponPoints(coupon_id); + let totalTaxed = this.get_total_with_tax(); + let totalUntaxed = this.get_total_without_tax(); + orderlines.forEach((line) => { + if (!line.reward_id) { + return; + } + const lineReward = this.pos.reward_by_id[line.reward_id]; + if (lineReward.reward_type !== 'discount') { + return; + } + const rewardProgram = lineReward.program_id; + if (rewardProgram.id === program.id || rewardProgram.trigger === 'auto') { + totalTaxed -= line.get_price_with_tax(); + totalUntaxed -= line.get_price_without_tax(); + } + }); + // Compute how much one unit costs in terms of points + const ruleData = []; + for (const rule of program.rules) { + if (!rule.any_product && !rule.valid_product_ids.has(product.id)) { + continue; + } + const amountToCheck = (rule.minimum_amount_tax_mode === 'incl' ? totalTaxed : totalUntaxed); + if (rule.minimum_amount > amountToCheck) { + continue; + } + let totalProductQty = 0; + let orderedProductPaid = 0; + orderlines.forEach((line) => { + if ((!line.reward_product_id && (rule.any_product || rule.valid_product_ids.has(line.get_product().id))) || + (line.reward_product_id && (rule.any_product || rule.valid_product_ids.has(line.reward_product_id)))) { + // We only count reward products from the same program to avoid unwanted feedback loops + if (line.reward_product_id) { + const reward = this.pos.reward_by_id[line.reward_id]; + if (program.id !== reward.program_id.id) { + return; + } + } + const lineQty = (line.reward_product_id ? -line.get_quantity() : line.get_quantity()); + totalProductQty += lineQty; + orderedProductPaid += line.get_price_with_tax(); + } + }); + const thisRuleChanges = {}; + thisRuleChanges.maxUnits = product.price ? Math.floor((amountToCheck - rule.minimum_amount) / product.price) : Infinity; + thisRuleChanges.maxUnits = Math.min(available - rule.minimum_qty, thisRuleChanges.maxUnits); + if (rule.reward_point_mode === 'unit') { + thisRuleChanges.pointsPerUnit = rule.reward_point_amount; + thisRuleChanges.pointsAdded = rule.reward_point_amount * totalProductQty; + } else if (rule.reward_point_mode === 'money') { + thisRuleChanges.pointsPerUnit = rule.reward_point_amount * productPriceWithTax; + thisRuleChanges.pointsAdded = round_precision(rule.reward_point_amount * orderedProductPaid, 0.01); + } else if (rule.reward_point_mode === 'order') { + thisRuleChanges.pointsPerUnit = 0; + thisRuleChanges.pointsAdded = rule.reward_point_amount; + } + ruleData.push(thisRuleChanges); + } + // We actually have to try out all possible amounts + const maximumQty = Math.min(available, Math.floor(points / reward.required_points) * reward.reward_product_qty); + let totalLostPerUnit = ruleData.reduce((sum, data) => sum + data.pointsPerUnit, 0); + let lostPoints = 0; + let units; + for (units = 1; units <= maximumQty; units++) { + let localLostPoints = lostPoints + totalLostPerUnit; + for (const data of ruleData) { + if (units === (data.maxUnits + 1)) { + localLostPoints -= data.pointsPerUnit * units; + totalLostPerUnit -= data.pointsPerUnit; + localLostPoints += data.pointsAdded; + } + } + const consumedPoints = Math.ceil(units / reward.reward_product_qty) * reward.required_points; + if (points - consumedPoints - localLostPoints < 0) { + units -= 1; + break; + } + lostPoints = localLostPoints; + } + available = Math.max(0, Math.min(units, available) - freeClaimed); + } + return available; + } + /** + * @param {Object} args See `_applyReward` + * @returns {Array} List of values to create the reward lines + */ + _getRewardLineValuesProduct(args) { + const reward = args['reward']; + const product = this.pos.db.get_product_by_id(args['product'] || reward.reward_product_ids[0]); + const availableQty = this._computeAvailableFreeProduct(reward, args['coupon_id'], product); + if (availableQty <= 0) { + return _t("There are not enough products in the basket to claim this reward."); + } + const points = this._getRealCouponPoints(args['coupon_id']); + const claimable_count = reward.clear_wallet ? 1 : Math.min(Math.ceil(availableQty / reward.reward_product_qty), Math.floor(points / reward.required_points)); + const cost = reward.clear_wallet ? points : claimable_count * reward.required_points; + return [{ + product: reward.discount_line_product_id, + price: -product.lst_price, + tax_ids: product.taxes_id, + // In case the reward is the product multiple times, give it as many times as possible + quantity: Math.min(availableQty, reward.reward_product_qty * claimable_count), + reward_id: reward.id, + is_reward_line: true, + reward_product_id: product.id, + coupon_id: args['coupon_id'], + points_cost: cost, + reward_identifier_code: _newRandomRewardCode(), + merge: false, + }] + } + /** + * Full routine for activating a code for the order. + * If only one more reward is claimable after activating the code, that reward is claimed + * directly, to avoid more steps than necessary. + * If more rewards are claimable, the employee will have to manually select the reward + * in the reward selection menu. + * + * @param {String} code + * @returns true if everything went right, error message if not. + */ + async _activateCode(code) { + const rule = this.pos.rules.find((rule) => { + return rule.mode === 'with_code' && (rule.promo_barcode === code || rule.code === code) + }); + let claimableRewards = null; + if (rule) { + if (this.codeActivatedProgramRules.includes(rule.id)) { + return _t('That promo code program has already been activated.'); + } + this.codeActivatedProgramRules.push(rule.id); + await this._updateLoyaltyPrograms(); + claimableRewards = this.getClaimableRewards(false, rule.program_id.id); + } else { + if (this.codeActivatedCoupons.find((coupon) => coupon.code === code)) { + return _t('That coupon code has already been scanned and activated.'); + } + const customer = this.get_partner(); + const { successful, payload } = await this.pos.env.services.rpc({ + model: 'pos.config', + method: 'use_coupon_code', + args: [ + [this.pos.config.id], + code, + this.creation_date, + customer ? customer.id : false, + ], + kwargs: { context: session.user_context }, + }); + if (successful) { + const coupon = new PosLoyaltyCard(code, payload.coupon_id, payload.program_id, payload.partner_id, payload.points); + this.pos.couponCache[coupon.id] = coupon; + this.codeActivatedCoupons.push(coupon); + await this._updateLoyaltyPrograms(); + claimableRewards = this.getClaimableRewards(coupon.id); + } else { + return payload.error_message; + } + } + if (claimableRewards && claimableRewards.length === 1) { + if (claimableRewards[0].reward.reward_type !== 'product' || !claimableRewards[0].reward.multi_product) { + this._applyReward(claimableRewards[0].reward, claimableRewards[0].coupon_id); + this._updateRewards(); + } + } + return true; + } + async activateCode(code) { + const res = await this._activateCode(code); + if (res !== true) { + Gui.showNotification(res); + } + } +} +Registries.Model.extend(Order, PosLoyaltyOrder); diff --git a/addons/pos_loyalty/static/src/js/OrderSummary.js b/addons/pos_loyalty/static/src/js/OrderSummary.js new file mode 100644 index 00000000000..0633890835c --- /dev/null +++ b/addons/pos_loyalty/static/src/js/OrderSummary.js @@ -0,0 +1,17 @@ +/** @odoo-module **/ + +import OrderSummary from 'point_of_sale.OrderSummary'; +import Registries from 'point_of_sale.Registries'; + +export const PosLoyaltyOrderSummary = (OrderSummary) => + class PosLoyaltyOrderSummary extends OrderSummary { + getLoyaltyPoints() { + if (!this.env.pos.config.loyalty_program_id) { + return {}; + } + const order = this.env.pos.get_order(); + return order.getLoyaltyPoints(); + } + }; + +Registries.Component.extend(OrderSummary, PosLoyaltyOrderSummary) diff --git a/addons/pos_loyalty/static/src/js/OrderWidget.js b/addons/pos_loyalty/static/src/js/OrderWidget.js new file mode 100644 index 00000000000..4efccdf3f46 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/OrderWidget.js @@ -0,0 +1,42 @@ +/** @odoo-module **/ + +import OrderWidget from 'point_of_sale.OrderWidget'; +import Registries from 'point_of_sale.Registries'; + +export const PosLoyaltyOrderWidget = (OrderWidget) => + class PosLoyaltyOrderWidget extends OrderWidget { + getActiveProgramsAndRewards() { + const order = this.env.pos.get_order(); + const activePrograms = Object.values(order.couponPointChanges).map((pe) => this.env.pos.program_by_id[pe.program_id]); + const seenRewards = new Set(); + const activeRewards = []; + for (const line of order._get_reward_lines()) { + const key = line.reward_id + '-' + line.coupon_id + '-' + line.reward_identifier_code; + if (seenRewards.has(key)) { + continue; + } + seenRewards.add(key); + const dbCoupon = this.env.pos.couponCache[line.coupon_id]; + const couponCode = dbCoupon ? dbCoupon.code : false; + activeRewards.push({ + id: line.cid, + name: line.get_product().display_name, + code: couponCode, + }); + } + const activeCoupons = order.codeActivatedCoupons.map((coupon) => { + const program = this.env.pos.program_by_id[coupon.program_id]; + return { + programName: program.name, + code: coupon.code, + }; + }); + return { + activePrograms, + activeCoupons, + activeRewards, + } + } + }; + +Registries.Component.extend(OrderWidget, PosLoyaltyOrderWidget); diff --git a/addons/pos_loyalty/static/src/js/Orderline.js b/addons/pos_loyalty/static/src/js/Orderline.js new file mode 100644 index 00000000000..425ec9fe8e4 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/Orderline.js @@ -0,0 +1,13 @@ +/** @odoo-module **/ + +import Orderline from 'point_of_sale.Orderline'; +import Registries from 'point_of_sale.Registries'; + +export const PosLoyaltyOrderline = (Orderline) => + class extends Orderline{ + get addedClasses() { + return Object.assign({'program-reward': this.props.line.is_reward_line}, super.addedClasses); + } + }; + +Registries.Component.extend(Orderline, PosLoyaltyOrderline); diff --git a/addons/pos_loyalty/static/src/js/PaymentScreen.js b/addons/pos_loyalty/static/src/js/PaymentScreen.js new file mode 100644 index 00000000000..002a4b5cab8 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/PaymentScreen.js @@ -0,0 +1,171 @@ +/** @odoo-module **/ + +import PaymentScreen from 'point_of_sale.PaymentScreen'; +import Registries from 'point_of_sale.Registries'; +import session from 'web.session'; +import { PosLoyaltyCard } from '@pos_loyalty/js/Loyalty'; + +export const PosLoyaltyPaymentScreen = (PaymentScreen) => + class extends PaymentScreen { + /** + * @override + */ + async validateOrder(isForceValidate) { + const pointChanges = {}; + const newCodes = []; + for (const pe of Object.values(this.currentOrder.couponPointChanges)) { + if (pe.coupon_id > 0) { + pointChanges[pe.coupon_id] = pe.points; + } else if (pe.barcode) { + // New coupon with a specific code, validate that it does not exist + newCodes.push(pe.barcode); + } + } + for (const line of this.currentOrder._get_reward_lines()) { + if (line.coupon_id < 1) { + continue; + } + if (!pointChanges[line.coupon_id]) { + pointChanges[line.coupon_id] = -line.points_cost; + } else { + pointChanges[line.coupon_id] -= line.points_cost; + } + } + // No need to do an rpc if no existing coupon is being used. + if (await this._isOrderValid(isForceValidate) && (!_.isEmpty(pointChanges) || newCodes.length)) { + try { + const {successful, payload} = await this.rpc({ + model: 'pos.order', + method: 'validate_coupon_programs', + args: [[], pointChanges, newCodes], + kwargs: { context: session.user_context }, + }); + // Payload may contain the points of the concerned coupons to be updated in case of error. (So that rewards can be corrected) + if (payload && payload.updated_points) { + for (const pointChange of Object.entries(payload.updated_points)) { + if (this.env.pos.couponCache[pointChange[0]]) { + this.env.pos.couponCache[pointChange[0]].balance = pointChange[1]; + } + } + } + if (payload && payload.removed_coupons) { + for (const couponId of payload.removed_coupons) { + if (this.env.pos.couponCache[couponId]) { + delete this.env.pos.couponCache[couponId]; + } + } + this.currentOrder.codeActivatedCoupons = this.currentOrder.codeActivatedCoupons.filter((coupon) => !payload.removed_coupons.includes(coupon.id)); + } + if (!successful) { + this.showPopup('ErrorPopup', { + title: this.env._t('Error validating rewards'), + body: payload.message, + }); + return; + } + } catch (_e) { + // Do nothing with error, while this validation step is nice for error messages + // it should not be blocking. + } + } + await super.validateOrder(...arguments); + } + + /** + * @override + */ + async _postPushOrderResolve(order, server_ids) { + // Compile data for our function + const rewardLines = order._get_reward_lines(); + const loyaltyProgramId = this.env.pos.config.loyalty_program_id ? this.env.pos.config.loyalty_program_id[0] : 0; + const partner = order.get_partner(); + let partnerLoyaltyCardId = partner ? partner.loyalty_card_id : 0; + let couponData = Object.values(order.couponPointChanges).reduce((agg, pe) => { + agg[pe.coupon_id] = Object.assign({}, pe); + const program = this.env.pos.program_by_id[pe.program_id]; + if (program.is_nominative && order.get_partner()) { + agg[pe.coupon_id].partner_id = partner.id; + } + if (program.id === loyaltyProgramId) { + if (!partnerLoyaltyCardId) { + partnerLoyaltyCardId = pe.coupon_id; + } + } + return agg; + }, {}); + for (const line of rewardLines) { + const reward = this.env.pos.reward_by_id[line.reward_id]; + if (!couponData[line.coupon_id]) { + couponData[line.coupon_id] = { + points: 0, + program_id: reward.program_id.id, + coupon_id: line.coupon_id, + barcode: false, + } + } + if (!couponData[line.coupon_id].line_codes) { + couponData[line.coupon_id].line_codes = []; + } + if (!couponData[line.coupon_id].line_codes.includes(line.reward_identifier_code)) { + !couponData[line.coupon_id].line_codes.push(line.reward_identifier_code); + } + couponData[line.coupon_id].points -= line.points_cost; + } + // We actually do not care about coupons for 'current' programs that did not claim any reward, they will be lost if not validated + couponData = Object.fromEntries(Object.entries(couponData).filter(([key, value]) => { + const program = this.env.pos.program_by_id[value.program_id]; + if (program.applies_on === 'current') { + return value.line_codes && value.line_codes.length; + } + return true; + })); + if (!_.isEmpty(couponData)) { + const payload = await this.rpc({ + model: 'pos.order', + method: 'confirm_coupon_programs', + args: [server_ids, couponData], + kwargs: { context: session.user_context }, + }); + if (payload.coupon_updates) { + for (const couponUpdate of payload.coupon_updates) { + let dbCoupon = this.env.pos.couponCache[couponUpdate.old_id]; + if (dbCoupon) { + dbCoupon.id = couponUpdate.id; + dbCoupon.balance = couponUpdate.points; + dbCoupon.code = couponUpdate.code; + } else { + dbCoupon = new PosLoyaltyCard( + couponUpdate.code, couponUpdate.id, couponUpdate.program_id, couponUpdate.partner_id, couponUpdate.points); + } + if (couponUpdate.old_id === partnerLoyaltyCardId) { + partner.loyalty_card_id = couponUpdate.id; + } + delete this.env.pos.couponCache[couponUpdate.old_id]; + this.env.pos.couponCache[couponUpdate.id] = dbCoupon; + } + } + // Update the usage count since it is checked based on local data + if (payload.program_updates) { + for (const programUpdate of payload.program_updates) { + const program = this.env.pos.program_by_id[programUpdate.program_id]; + if (program) { + program.total_order_count = programUpdate.usages; + } + } + } + if (payload.coupon_report) { + for (const report_entry of Object.entries(payload.coupon_report)) { + this.env.legacyActionManager.do_action(report_entry[0], { + additional_context: { + active_ids: report_entry[1], + } + }); + } + } + order.new_coupon_info = payload.new_coupon_info; + } + return super._postPushOrderResolve(order, server_ids); + } + }; + +Registries.Component.extend(PaymentScreen, PosLoyaltyPaymentScreen); diff --git a/addons/pos_loyalty/static/src/js/Popups/GiftCardPopup.js b/addons/pos_loyalty/static/src/js/Popups/GiftCardPopup.js new file mode 100644 index 00000000000..b6cdae2fd36 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/Popups/GiftCardPopup.js @@ -0,0 +1,142 @@ +/** @odoo-module **/ + +import AbstractAwaitablePopup from 'point_of_sale.AbstractAwaitablePopup'; +import Registries from 'point_of_sale.Registries'; +import { useBarcodeReader } from 'point_of_sale.custom_hooks'; +import Core from 'web.core'; + +const _t = Core._t; +const { useState } = owl; + +export class GiftCardPopup extends AbstractAwaitablePopup { + setup() { + super.setup(); + + this.confirmFunctions = { + 'create_set': this.generateBarcode.bind(this), + 'scan_set': this.scanAndUseGiftCard.bind(this), + 'scan_use': this.scanAndUseGiftCard.bind(this), + 'pay': this.payWithGiftCard.bind(this), + 'balance': this.showRemainingAmount.bind(this), + }; + + this.state = useState({ + giftCardConfig: this.env.pos.config.gift_card_settings, + showMenu: true, + error: '', + context: '', + amountToSet: 0, + code: '', + }); + + useBarcodeReader({ + gift_card: this._onScan, + }, true); + } + + clickConfirm() { + this.confirmFunctions[this.state.context](); + } + + get code() { + return this.state.code.trim(); + } + + switchToMenu() { + this.state.showMenu = true; + this.state.context = ''; + this.state.amountToSet = 0; + this.state.code = ''; + this.state.error = ''; + } + + switchToBarcode() { + this.state.context = this.state.giftCardConfig; + this.state.showMenu = false; + } + + switchToPay() { + this.state.showMenu = false; + this.state.context = 'pay'; + } + + switchToBalance() { + this.state.showMenu = false; + this.state.context = 'balance'; + } + + _onScan(code) { + this.state.code = code.base_code; + } + + _getGiftCardProduct() { + const pos = this.env.pos; + const program = pos.program_by_id[pos.config.gift_card_program_id[0]]; + return pos.db.product_by_id[[...program.rules[0].valid_product_ids][0]]; + } + + addGiftCardProduct() { + const pos = this.env.pos; + const order = pos.get_order(); + const product = this._getGiftCardProduct(); + order.add_product(product, { + price: this.state.amountToSet, + quantity: 1, + merge: false, + giftBarcode: this.code || false, + }); + } + + async generateBarcode() { + this.addGiftCardProduct(false); + this.confirm(); + } + + scanAndUseGiftCard() { + if (this.state.giftCardConfig === "scan_use") { + // Use the default price of the first product to match + this.state.amountToSet = this._getGiftCardProduct().price; + } + this.addGiftCardProduct(); + this.confirm(); + } + + async payWithGiftCard() { + if (!this.code) { + this.state.error = _t('No gift card code set'); + return; + } + // This should load and enable the coupon and automatic rewards since the gift_card program is supposed to only have one + // (as long as there are enough points). + const res = await this.env.pos.get_order().activateCode(this.code); + if (res !== true) { + this.state.error = res; + } + this.confirm(); + } + + async showRemainingAmount() { + this.state.amountToSet = 0; + if (!this.code) { + this.state.error = _t('No gift card code set'); + return; + } + const coupon = await this.rpc({ + model: 'loyalty.card', + method: 'search_read', + args: [ + [['code', '=', this.code], ['program_id', '=', this.env.pos.config.gift_card_program_id[0]]], + ['points'], + ], + }); + if (!coupon || !coupon.length) { + this.state.error = _t('No gift card found'); + } else { + this.state.amountToSet = coupon[0].points; + } + } +} + +GiftCardPopup.template = 'GiftCardPopup'; + +Registries.Component.add(GiftCardPopup); diff --git a/addons/pos_loyalty/static/src/js/ProductScreen.js b/addons/pos_loyalty/static/src/js/ProductScreen.js new file mode 100644 index 00000000000..c2e6b1f3bc5 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/ProductScreen.js @@ -0,0 +1,82 @@ +/** @odoo-module **/ + +import ProductScreen from 'point_of_sale.ProductScreen'; +import Registries from 'point_of_sale.Registries'; +import { useBarcodeReader } from 'point_of_sale.custom_hooks'; + +export const PosLoyaltyProductScreen = (ProductScreen) => + class extends ProductScreen { + setup() { + super.setup(); + useBarcodeReader({ + coupon: this._onCouponScan, + }); + } + + _onCouponScan(code) { + this.currentOrder.activateCode(code.base_code); + } + + async _updateSelectedOrderline(event) { + const selectedLine = this.currentOrder.get_selected_orderline(); + if (selectedLine && selectedLine.is_reward_line && !selectedLine.manual_reward && + (event.detail.key === 'Backspace' || event.detail.key === 'Delete')) { + const reward = this.env.pos.reward_by_id[selectedLine.reward_id]; + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: this.env._t('Deactivating reward'), + body: _.str.sprintf( + this.env._t('Are you sure you want to remove %s from this order?\n You will still be able to claim it through the reward button.'), + reward.description + ), + cancelText: this.env._t('No'), + confirmText: this.env._t('Yes'), + }); + if (confirmed) { + event.detail.buffer = null; + } else { + // Cancel backspace + return; + } + } + return super._updateSelectedOrderline(...arguments); + } + + + /** + * 1/ Perform the usual set value operation (super._setValue) if the line being modified + * is not a reward line or if it is a reward line, the `val` being set is '' or 'remove' only. + * + * 2/ Update activated programs and coupons when removing a reward line. + * + * 3/ Trigger 'update-rewards' if the line being modified is a regular line or + * if removing a reward line. + * + * @override + */ + _setValue(val) { + const selectedLine = this.currentOrder.get_selected_orderline(); + if ( + !selectedLine || + !selectedLine.is_reward_line || + (selectedLine.is_reward_line && ['', 'remove'].includes(val)) + ) { + super._setValue(val); + } + if (!selectedLine) return; + if (selectedLine.is_reward_line && val === 'remove') { + this.currentOrder.disabledRewards.push(selectedLine.reward_id); + const coupon = this.env.pos.couponCache[selectedLine.coupon_id]; + if (coupon && coupon.id > 0 && this.currentOrder.codeActivatedCoupons.find((c) => c.code === coupon.code)) { + delete this.env.pos.couponCache[selectedLine.coupon_id]; + this.currentOrder.codeActivatedCoupons.splice(this.currentOrder.codeActivatedCoupons.findIndex((coupon) => { + return coupon.id === selectedLine.coupon_id; + }), 1); + } + } + if (!selectedLine.is_reward_line || (selectedLine.is_reward_line && val === 'remove')) { + selectedLine.order._updateRewards(); + } + } + }; + +Registries.Component.extend(ProductScreen, PosLoyaltyProductScreen); diff --git a/addons/pos_loyalty/static/src/js/tours/PosLoyaltyTour.js b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyTour.js new file mode 100644 index 00000000000..5293c23cd35 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyTour.js @@ -0,0 +1,158 @@ +/** @odoo-module **/ + +import { PosLoyalty } from 'pos_loyalty.tour.PosCouponTourMethods'; +import { ProductScreen } from 'point_of_sale.tour.ProductScreenTourMethods'; +import { getSteps, startSteps } from 'point_of_sale.tour.utils'; +import Tour from 'web_tour.tour'; + +// --- PoS Loyalty Tour Basic Part 1 --- +// Generate coupons for PosLoyaltyTour2. +startSteps(); + +ProductScreen.do.confirmOpeningPopup(); +ProductScreen.do.clickHomeCategory(); + +// basic order +// just accept the automatically applied promo program +// applied programs: +// - on cheapest product +ProductScreen.exec.addOrderline('Whiteboard Pen', '5'); +PosLoyalty.check.hasRewardLine('90% on the cheapest product', '-2.88'); +PosLoyalty.do.selectRewardLine('on the cheapest product'); +PosLoyalty.check.orderTotalIs('13.12'); +PosLoyalty.exec.finalizeOrder('Cash', '20'); + +// remove the reward from auto promo program +// no applied programs +ProductScreen.exec.addOrderline('Whiteboard Pen', '6'); +PosLoyalty.check.hasRewardLine('on the cheapest product', '-2.88'); +PosLoyalty.check.orderTotalIs('16.32'); +PosLoyalty.exec.removeRewardLine('90% on the cheapest product'); +PosLoyalty.check.orderTotalIs('19.2'); +PosLoyalty.exec.finalizeOrder('Cash', '20'); + +// order with coupon code from coupon program +// applied programs: +// - coupon program +ProductScreen.exec.addOrderline('Desk Organizer', '9'); +PosLoyalty.check.hasRewardLine('on the cheapest product', '-4.59'); +PosLoyalty.exec.removeRewardLine('90% on the cheapest product'); +PosLoyalty.check.orderTotalIs('45.90'); +PosLoyalty.do.enterCode('invalid_code', false); +PosLoyalty.do.enterCode('1234'); +PosLoyalty.check.hasRewardLine('Free Product - Desk Organizer', '-15.30'); +PosLoyalty.exec.finalizeOrder('Cash', '50'); + +// Use coupon but eventually remove the reward +// applied programs: +// - on cheapest product +ProductScreen.exec.addOrderline('Letter Tray', '4'); +ProductScreen.exec.addOrderline('Desk Organizer', '9'); +PosLoyalty.check.hasRewardLine('90% on the cheapest product', '-4.75'); +PosLoyalty.check.orderTotalIs('62.27'); +PosLoyalty.do.enterCode('5678'); +PosLoyalty.check.hasRewardLine('Free Product - Desk Organizer', '-15.30'); +PosLoyalty.check.orderTotalIs('46.97'); +PosLoyalty.exec.removeRewardLine('Free Product'); +PosLoyalty.check.orderTotalIs('62.27'); +PosLoyalty.exec.finalizeOrder('Cash', '90'); + +// specific product discount +// applied programs: +// - on cheapest product +// - on specific products +ProductScreen.exec.addOrderline('Magnetic Board', '10') // 1.98 +ProductScreen.exec.addOrderline('Desk Organizer', '3') // 5.1 +ProductScreen.exec.addOrderline('Letter Tray', '4') // 4.8 tax 10% +PosLoyalty.check.hasRewardLine('90% on the cheapest product', '-1.78') +PosLoyalty.check.orderTotalIs('54.44') +PosLoyalty.do.enterCode('promocode', false) +PosLoyalty.check.hasRewardLine('50% on specific products', '-16.66') // 17.55 - 1.78*0.5 +PosLoyalty.check.orderTotalIs('37.78') +PosLoyalty.exec.finalizeOrder('Cash', '50') + +Tour.register('PosLoyaltyTour1', { test: true, url: '/pos/web' }, getSteps()); + +// --- PoS Loyalty Tour Basic Part 2 --- +// Using the coupons generated from PosLoyaltyTour1. +startSteps(); + +ProductScreen.do.clickHomeCategory(); + +// Test that global discount and cheapest product discounts can be accumulated. +// Applied programs: +// - global discount +// - on cheapest discount +ProductScreen.exec.addOrderline('Desk Organizer', '10'); // 5.1 +PosLoyalty.check.hasRewardLine('on the cheapest product', '-4.59'); +ProductScreen.exec.addOrderline('Letter Tray', '4'); // 4.8 tax 10% +PosLoyalty.check.hasRewardLine('on the cheapest product', '-4.75'); +PosLoyalty.do.enterCode('123456'); +PosLoyalty.check.hasRewardLine('10% on your order', '-5.10'); +PosLoyalty.check.hasRewardLine('10% on your order', '-1.64'); +PosLoyalty.check.orderTotalIs('60.63'); //SUBTOTAL +PosLoyalty.exec.finalizeOrder('Cash', '70'); + +// Scanning coupon twice. +// Also apply global discount on top of free product to check if the +// calculated discount is correct. +// Applied programs: +// - coupon program (free product) +// - global discount +// - on cheapest discount +ProductScreen.exec.addOrderline('Desk Organizer', '11'); // 5.1 per item +PosLoyalty.check.hasRewardLine('90% on the cheapest product', '-4.59'); +PosLoyalty.check.orderTotalIs('51.51'); +// add global discount and the discount will be replaced +PosLoyalty.do.enterCode('345678'); +PosLoyalty.check.hasRewardLine('10% on your order', '-5.15'); +// add free product coupon (for qty=11, free=4) +// the discount should change after having free products +// it should go back to cheapest discount as it is higher +PosLoyalty.do.enterCode('5678'); +PosLoyalty.check.hasRewardLine('Free Product - Desk Organizer', '-20.40'); +PosLoyalty.check.hasRewardLine('90% on the cheapest product', '-4.59'); +// set quantity to 18 +// free qty stays the same since the amount of points on the card only allows for 4 free products +ProductScreen.do.pressNumpad('Backspace 8') +PosLoyalty.check.hasRewardLine('10% on your order', '-6.68'); +PosLoyalty.check.hasRewardLine('Free Product - Desk Organizer', '-20.40'); +// scan the code again and check notification +PosLoyalty.do.enterCode('5678'); +PosLoyalty.check.orderTotalIs('60.13'); +PosLoyalty.exec.finalizeOrder('Cash', '65'); + +// Specific products discount (with promocode) and free product (1357) +// Applied programs: +// - discount on specific products +// - free product +ProductScreen.exec.addOrderline('Desk Organizer', '6'); // 5.1 per item +PosLoyalty.check.hasRewardLine('on the cheapest product', '-4.59'); +PosLoyalty.exec.removeRewardLine('90% on the cheapest product'); +PosLoyalty.do.enterCode('promocode', false); +PosLoyalty.check.hasRewardLine('50% on specific products', '-15.30'); +PosLoyalty.do.enterCode('1357'); +PosLoyalty.check.hasRewardLine('Free Product - Desk Organizer', '-10.20'); +PosLoyalty.check.hasRewardLine('50% on specific products', '-10.20'); +PosLoyalty.check.orderTotalIs('10.20'); +PosLoyalty.exec.finalizeOrder('Cash', '20'); + +// Check reset program +// Enter two codes and reset the programs. +// The codes should be checked afterwards. They should return to new. +// Applied programs: +// - cheapest product +ProductScreen.exec.addOrderline('Monitor Stand', '6'); // 3.19 per item +PosLoyalty.do.enterCode('098765'); +PosLoyalty.check.hasRewardLine('90% on the cheapest product', '-2.87'); +PosLoyalty.check.hasRewardLine('10% on your order', '-1.63'); +PosLoyalty.check.orderTotalIs('14.64'); +PosLoyalty.exec.removeRewardLine('90% on the cheapest product'); +PosLoyalty.check.hasRewardLine('10% on your order', '-1.91'); +PosLoyalty.check.orderTotalIs('17.23'); +PosLoyalty.do.resetActivePrograms(); +PosLoyalty.check.hasRewardLine('90% on the cheapest product', '-2.87'); +PosLoyalty.check.orderTotalIs('16.27'); +PosLoyalty.exec.finalizeOrder('Cash', '20'); + +Tour.register('PosLoyaltyTour2', { test: true, url: '/pos/web' }, getSteps()); diff --git a/addons/pos_coupon/static/src/js/tours/PosCouponTourMethods.js b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyTourMethods.js similarity index 72% rename from addons/pos_coupon/static/src/js/tours/PosCouponTourMethods.js rename to addons/pos_loyalty/static/src/js/tours/PosLoyaltyTourMethods.js index e507ceb9fe7..eb0096d5576 100644 --- a/addons/pos_coupon/static/src/js/tours/PosCouponTourMethods.js +++ b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyTourMethods.js @@ -1,4 +1,4 @@ -odoo.define('pos_coupon.tour.PosCouponTourMethods', function (require) { +odoo.define('pos_loyalty.tour.PosCouponTourMethods', function (require) { 'use strict'; const { createTourMethods } = require('point_of_sale.tour.utils'); @@ -26,8 +26,8 @@ odoo.define('pos_coupon.tour.PosCouponTourMethods', function (require) { }, ]; } - enterCode(code) { - return [ + enterCode(code, valid=true) { + const steps = [ { content: 'open code input dialog', trigger: '.control-button:contains("Enter Code")', @@ -42,6 +42,14 @@ odoo.define('pos_coupon.tour.PosCouponTourMethods', function (require) { trigger: '.popup-textinput .button.confirm', }, ]; + if (valid) { + steps.push({ + content: 'wait for the coupon to be loaded', + trigger: `.active-coupon:contains("(${code})")`, + run: () => {}, + }); + } + return steps; } resetActivePrograms() { return [ @@ -51,6 +59,26 @@ odoo.define('pos_coupon.tour.PosCouponTourMethods', function (require) { }, ]; } + claimSingleReward() { + return [ + { + content: 'open reward dialog', + trigger: '.control-button:contains("Reward")', + }, + ]; + } + claimReward(rewardName) { + return [ + { + content: 'open reward dialog', + trigger: '.control-button:contains("Reward")', + }, + { + content: 'select reward', + trigger: `.selection-item:contains("${rewardName}")`, + } + ]; + } } class Check { @@ -77,6 +105,15 @@ odoo.define('pos_coupon.tour.PosCouponTourMethods', function (require) { }, ]; } + checkNoClaimableRewards() { + return [ + { + content: 'check that no reward can be claimed', + trigger: ".control-button:contains('Reward'):not(.highlight)", + run: function () {}, // it's a check + } + ] + } } class Execute { @@ -102,5 +139,5 @@ odoo.define('pos_coupon.tour.PosCouponTourMethods', function (require) { } } - return createTourMethods('PosCoupon', Do, Check, Execute); + return createTourMethods('PosLoyalty', Do, Check, Execute); }); diff --git a/addons/pos_loyalty/static/src/js/tours/PosLoyaltyValidityTour.js b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyValidityTour.js new file mode 100644 index 00000000000..1fca6847003 --- /dev/null +++ b/addons/pos_loyalty/static/src/js/tours/PosLoyaltyValidityTour.js @@ -0,0 +1,36 @@ +/** @odoo-module **/ + +import { PosLoyalty } from 'pos_loyalty.tour.PosCouponTourMethods'; +import { ProductScreen } from 'point_of_sale.tour.ProductScreenTourMethods'; +import { getSteps, startSteps } from 'point_of_sale.tour.utils'; +import Tour from 'web_tour.tour'; + +// First tour should not get any automatic rewards +startSteps(); + +ProductScreen.do.confirmOpeningPopup(); +ProductScreen.do.clickHomeCategory(); + +// Not valid -> date +ProductScreen.exec.addOrderline('Whiteboard Pen', '5'); +PosLoyalty.check.checkNoClaimableRewards(); +PosLoyalty.exec.finalizeOrder('Cash', '20'); + +Tour.register('PosLoyaltyValidity1', { test: true, url: '/pos/web' }, getSteps()); + +// Second tour +startSteps(); + +ProductScreen.do.clickHomeCategory(); + +// Valid +ProductScreen.exec.addOrderline('Whiteboard Pen', '5'); +PosLoyalty.check.hasRewardLine('90% on the cheapest product', '-2.88'); +PosLoyalty.exec.finalizeOrder('Cash', '20'); + +// Not valid -> usage +ProductScreen.exec.addOrderline('Whiteboard Pen', '5'); +PosLoyalty.check.checkNoClaimableRewards(); +PosLoyalty.exec.finalizeOrder('Cash', '20'); + +Tour.register('PosLoyaltyValidity2', { test: true, url: '/pos/web' }, getSteps()); diff --git a/addons/pos_gift_card/static/src/xml/GiftCardButton.xml b/addons/pos_loyalty/static/src/xml/ControlButtons/GiftCardButton.xml similarity index 100% rename from addons/pos_gift_card/static/src/xml/GiftCardButton.xml rename to addons/pos_loyalty/static/src/xml/ControlButtons/GiftCardButton.xml diff --git a/addons/pos_coupon/static/src/xml/ControlButtons/PromoCodeButton.xml b/addons/pos_loyalty/static/src/xml/ControlButtons/PromoCodeButton.xml similarity index 100% rename from addons/pos_coupon/static/src/xml/ControlButtons/PromoCodeButton.xml rename to addons/pos_loyalty/static/src/xml/ControlButtons/PromoCodeButton.xml diff --git a/addons/pos_coupon/static/src/xml/ControlButtons/ResetProgramsButton.xml b/addons/pos_loyalty/static/src/xml/ControlButtons/ResetProgramsButton.xml similarity index 100% rename from addons/pos_coupon/static/src/xml/ControlButtons/ResetProgramsButton.xml rename to addons/pos_loyalty/static/src/xml/ControlButtons/ResetProgramsButton.xml diff --git a/addons/pos_loyalty/static/src/xml/ControlButtons/RewardButton.xml b/addons/pos_loyalty/static/src/xml/ControlButtons/RewardButton.xml new file mode 100644 index 00000000000..4520a408d03 --- /dev/null +++ b/addons/pos_loyalty/static/src/xml/ControlButtons/RewardButton.xml @@ -0,0 +1,12 @@ + + + + + + + + Reward + + + + diff --git a/addons/pos_loyalty/static/src/xml/OrderReceipt.xml b/addons/pos_loyalty/static/src/xml/OrderReceipt.xml new file mode 100644 index 00000000000..18e244d1056 --- /dev/null +++ b/addons/pos_loyalty/static/src/xml/OrderReceipt.xml @@ -0,0 +1,62 @@ + + + + + + +
+ +
--------------------------------
+
+
+
+
+
+ + +
Won:
+
+ +
Spent:
+
+ +
Total :
+
+
+
+ + +
+
------------------------
+
+
+ Coupon Codes +
+ +
+
+ +
+
+ Valid until: + + + + + no expiration + +
+
+ Barcode +
+
+ +
+
+
+
+
+ + + + diff --git a/addons/pos_loyalty/static/src/xml/OrderSummary.xml b/addons/pos_loyalty/static/src/xml/OrderSummary.xml new file mode 100644 index 00000000000..d4df290f2b1 --- /dev/null +++ b/addons/pos_loyalty/static/src/xml/OrderSummary.xml @@ -0,0 +1,31 @@ + + + + + + +
+
+ +
+ +
+ + +
+
+ +
+ - +
+
+
+ +
+
+
+ +
+
+
+
+
diff --git a/addons/pos_loyalty/static/src/xml/OrderWidget.xml b/addons/pos_loyalty/static/src/xml/OrderWidget.xml new file mode 100644 index 00000000000..eb1b3d95ae1 --- /dev/null +++ b/addons/pos_loyalty/static/src/xml/OrderWidget.xml @@ -0,0 +1,34 @@ + + + + + +
+
+
Active Programs
+ +
+ +
+
+
+
+
Active Coupons
+ +
+ () +
+
+
+
+
Active Rewards
+ +
+ +
+
+
+
+
+
+
diff --git a/addons/pos_loyalty/static/src/xml/Popups/GiftCardPopup.xml b/addons/pos_loyalty/static/src/xml/Popups/GiftCardPopup.xml new file mode 100644 index 00000000000..9cfc77cbcac --- /dev/null +++ b/addons/pos_loyalty/static/src/xml/Popups/GiftCardPopup.xml @@ -0,0 +1,106 @@ + + + + + + + + + + diff --git a/addons/pos_coupon/tests/__init__.py b/addons/pos_loyalty/tests/__init__.py similarity index 100% rename from addons/pos_coupon/tests/__init__.py rename to addons/pos_loyalty/tests/__init__.py diff --git a/addons/pos_loyalty/tests/test_frontend.py b/addons/pos_loyalty/tests/test_frontend.py new file mode 100644 index 00000000000..773946bebb0 --- /dev/null +++ b/addons/pos_loyalty/tests/test_frontend.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import date, timedelta + +from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon +from odoo.tests import Form, tagged + + +@tagged("post_install", "-at_install") +class TestUi(TestPointOfSaleHttpCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Disable any programs during the test + cls.env['loyalty.program'].search([]).write({'active': False}) + + cls.promo_programs = cls.env["loyalty.program"] + + # code promo program -> discount on specific products + cls.code_promo_program = cls.env['loyalty.program'].create({ + 'name': 'Promo Code Program - Discount on Specific Products', + 'program_type': 'promotion', + 'trigger': 'with_code', + 'applies_on': 'current', + 'rule_ids': [(0, 0, { + 'mode': 'with_code', + 'code': 'promocode', + })], + 'reward_ids': [(0, 0, { + 'reward_type': 'discount', + 'discount': 50, + 'discount_mode': 'percent', + 'discount_applicability': 'specific', + 'discount_product_ids': cls.whiteboard_pen | cls.magnetic_board | cls.desk_organizer, + })], + }) + cls.promo_programs |= cls.code_promo_program + + # auto promo program on current order + # -> discount on cheapest product + cls.auto_promo_program_current = cls.env['loyalty.program'].create({ + 'name': 'Auto Promo Program - Cheapest Product', + 'program_type': 'promotion', + 'trigger': 'auto', + 'rule_ids': [(0, 0, {})], + 'reward_ids': [(0, 0, { + 'reward_type': 'discount', + 'discount': 90, + 'discount_mode': 'percent', + 'discount_applicability': 'cheapest', + })] + }) + cls.promo_programs |= cls.auto_promo_program_current + + # auto promo program on next order + # -> discount on order (global discount) + cls.auto_promo_program_next = cls.env['loyalty.program'].create({ + 'name': 'Auto Promo Program - Global Discount', + 'program_type': 'promotion', + 'trigger': 'auto', + 'applies_on': 'future', + 'rule_ids': [(0, 0, {})], + 'reward_ids': [(0, 0, { + 'reward_type': 'discount', + 'discount': 10, + 'discount_mode': 'percent', + 'discount_applicability': 'order', + })] + }) + cls.promo_programs |= cls.auto_promo_program_next + + # coupon program -> free product + cls.coupon_program = cls.env['loyalty.program'].create({ + 'name': 'Coupon Program - Buy 3 Take 2 Free Product', + 'program_type': 'coupons', + 'trigger': 'with_code', + 'applies_on': 'current', + 'rule_ids': [(0, 0, { + 'product_ids': cls.desk_organizer, + 'reward_point_mode': 'unit', + 'minimum_qty': 3, + })], + 'reward_ids': [(0, 0, { + 'reward_type': 'product', + 'reward_product_id': cls.desk_organizer.id, + 'reward_product_qty': 1, + 'required_points': 1.5, + })], + }) + + # Create coupons for the coupon program and change the code + # to be able to use them in the frontend tour. + cls.env["loyalty.generate.wizard"].with_context( + {"active_id": cls.coupon_program.id} + ).create({"coupon_qty": 4, 'points_granted': 4.5}).generate_coupons() + cls.coupon1, cls.coupon2, cls.coupon3, cls.coupon4 = cls.coupon_program.coupon_ids + cls.coupon1.write({"code": "1234"}) + cls.coupon2.write({"code": "5678"}) + cls.coupon3.write({"code": "1357"}) + cls.coupon4.write({"code": "2468"}) + + def setUp(self): + super().setUp() + # Set the programs to the pos config. + # Remove fiscal position and pricelist. + with Form(self.main_pos_config) as pos_config: + pos_config.tax_regime_selection = False + pos_config.use_pricelist = False + pos_config.pricelist_id = self.env["product.pricelist"].create( + {"name": "PoS Default Pricelist",} + ) + pos_config.use_coupon_programs = True + pos_config.coupon_program_ids.add(self.coupon_program) + for promo_program in self.promo_programs: + pos_config.promo_program_ids.add(promo_program) + self.main_pos_config.open_session_cb() + + + + def test_pos_loyalty_tour_basic(self): + """PoS Loyalty Basic Tour""" + ## + # Tour Part 1 + # This part will generate coupons for `auto_promo_program_next` + # that will be used in the second part of the tour. + # + + self.start_tour( + "/pos/web?config_id=%d" % self.main_pos_config.id, + "PosLoyaltyTour1", + login="accountman", + ) + + # check coupon usage + self.assertEqual(self.coupon1.points, 0, 'The coupon should have consumed its points.') + self.assertEqual(self.coupon2.points, 4.5, 'The coupon was used but never validated.') + # check pos_order_count in each program + self.assertEqual(self.auto_promo_program_current.pos_order_count, 3) + self.assertEqual(self.auto_promo_program_next.pos_order_count, 0) + self.assertEqual(self.code_promo_program.pos_order_count, 1) + self.assertEqual(self.coupon_program.pos_order_count, 1) + # check number of generated coupons + self.assertEqual(len(self.auto_promo_program_next.coupon_ids), 5) + # check number of orders in the session + pos_session = self.main_pos_config.current_session_id + self.assertEqual( + len(pos_session.order_ids), 5, msg="5 orders were made in tour part1." + ) + + ## + # Tour Part 2 + # The coupons generated in the first part will be used in this tour. + # + + # Manually set the code for some `auto_promo_program_next` coupons + # to be able to use them in defining the part2 tour. + ( + promo_coupon1, + promo_coupon2, + promo_coupon3, + promo_coupon4, + *_, + ) = self.auto_promo_program_next.coupon_ids + promo_coupon1.write({"code": "123456"}) + promo_coupon2.write({"code": "345678"}) + promo_coupon3.write({"code": "567890"}) + promo_coupon4.write({"code": "098765"}) + + self.coupon2.points = 6 + self.coupon3.points = 3 + + # use here the generated coupon + self.start_tour( + "/pos/web?config_id=%d" % self.main_pos_config.id, + "PosLoyaltyTour2", + login="accountman", + ) + # check pos_order_count in each program + self.assertEqual(self.auto_promo_program_current.pos_order_count, 6) + self.assertEqual(self.auto_promo_program_next.pos_order_count, 2) + self.assertEqual(self.code_promo_program.pos_order_count, 2) + self.assertEqual(self.coupon_program.pos_order_count, 3) + + def test_loyalty_validity_dates_and_use(self): + # Tests date validity and max usage for an automatic program. + self.auto_promo_program_current.write({ + 'date_to': date.today() - timedelta(days=2), + 'limit_usage': True, + 'max_usage': 1, + }) + # First tour check that the promotion is not applied + self.start_tour( + "/pos/web?config_id=%d" % self.main_pos_config.id, + "PosLoyaltyValidity1", + login="accountman", + ) + + self.auto_promo_program_current.write({ + 'date_to': date.today() + timedelta(days=2), + }) + + # Second tour that does 2 orders, the first should have the rewards, the second should not + self.start_tour( + "/pos/web?config_id=%d" % self.main_pos_config.id, + "PosLoyaltyValidity2", + login="accountman", + ) diff --git a/addons/pos_loyalty/views/loyalty_card_views.xml b/addons/pos_loyalty/views/loyalty_card_views.xml new file mode 100644 index 00000000000..ed1dd91df29 --- /dev/null +++ b/addons/pos_loyalty/views/loyalty_card_views.xml @@ -0,0 +1,13 @@ + + + + loyalty.card.view.form.inherit.pos.loyalty + loyalty.card + + + + + + + + diff --git a/addons/pos_loyalty/views/loyalty_mail_views.xml b/addons/pos_loyalty/views/loyalty_mail_views.xml new file mode 100644 index 00000000000..f69a8b66f12 --- /dev/null +++ b/addons/pos_loyalty/views/loyalty_mail_views.xml @@ -0,0 +1,13 @@ + + + + loyalty.mail.view.tree.inherit.pos.loyalty + loyalty.mail + + + + + + + + diff --git a/addons/pos_coupon/views/pos_config_views.xml b/addons/pos_loyalty/views/pos_config_views.xml similarity index 59% rename from addons/pos_coupon/views/pos_config_views.xml rename to addons/pos_loyalty/views/pos_config_views.xml index 80f4194730c..68bba42cbca 100644 --- a/addons/pos_coupon/views/pos_config_views.xml +++ b/addons/pos_loyalty/views/pos_config_views.xml @@ -1,11 +1,36 @@ - - pos.config.form + + pos.config.view.form.inherit.pos.loyalty pos.config +
+
+
+
+ +
+
+ +
+
+
+
+
@@ -22,7 +47,7 @@ + domain="[('program_type', '=', 'promotion'), ('active', '=', True)]" />
@@ -31,7 +56,7 @@ + domain="[('program_type', '=', 'coupons'), ('active', '=', True)]"/>
diff --git a/addons/pos_loyalty/views/pos_loyalty_menu_views.xml b/addons/pos_loyalty/views/pos_loyalty_menu_views.xml new file mode 100644 index 00000000000..66dcb56e4dc --- /dev/null +++ b/addons/pos_loyalty/views/pos_loyalty_menu_views.xml @@ -0,0 +1,11 @@ + + + + diff --git a/addons/sale/models/res_config_settings.py b/addons/sale/models/res_config_settings.py index a077b690030..806a81187d9 100644 --- a/addons/sale/models/res_config_settings.py +++ b/addons/sale/models/res_config_settings.py @@ -77,7 +77,7 @@ class ResConfigSettings(models.TransientModel): module_product_email_template = fields.Boolean("Specific Email") module_sale_amazon = fields.Boolean("Amazon Sync") - module_sale_coupon = fields.Boolean("Coupons & Promotions") + module_sale_loyalty = fields.Boolean("Coupons & Loyalty") module_sale_margin = fields.Boolean("Margins") #=== ONCHANGE METHODS ===# diff --git a/addons/sale/views/res_config_settings_views.xml b/addons/sale/views/res_config_settings_views.xml index b7bec091b48..2b778e70fbe 100644 --- a/addons/sale/views/res_config_settings_views.xml +++ b/addons/sale/views/res_config_settings_views.xml @@ -105,10 +105,10 @@ id="coupon_settings" title="Boost your sales with two kinds of discount programs: promotions and coupon codes. Specific conditions can be set (products, customers, minimum purchase amount, period). Rewards can be discounts (% or amount) or free products.">
- +
-
+ title="Boost your sales with multiple kinds of programs: Coupons, Promotions, Gift Card, Loyalty. Specific conditions can be set (products, customers, minimum purchase amount, period). Rewards can be discounts (% or amount) or free products.">
- +
-
+
+ + + + diff --git a/addons/website_sale_loyalty/views/website_sale_templates.xml b/addons/website_sale_loyalty/views/website_sale_templates.xml new file mode 100644 index 00000000000..33091ffb6a7 --- /dev/null +++ b/addons/website_sale_loyalty/views/website_sale_templates.xml @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/website_sale_coupon/wizard/__init__.py b/addons/website_sale_loyalty/wizard/__init__.py similarity index 77% rename from addons/website_sale_coupon/wizard/__init__.py rename to addons/website_sale_loyalty/wizard/__init__.py index 4731e9ff622..3d4fe11e986 100644 --- a/addons/website_sale_coupon/wizard/__init__.py +++ b/addons/website_sale_loyalty/wizard/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import loyalty_generate_wizard from . import sale_coupon_share diff --git a/addons/website_sale_loyalty/wizard/loyalty_generate_wizard.py b/addons/website_sale_loyalty/wizard/loyalty_generate_wizard.py new file mode 100644 index 00000000000..828ab3fe592 --- /dev/null +++ b/addons/website_sale_loyalty/wizard/loyalty_generate_wizard.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + +class LoyaltyGenerateWizard(models.TransientModel): + _inherit = 'loyalty.generate.wizard' + + website_id = fields.Many2one('website', 'Website') + + def _get_coupon_values(self, partner): + res = super()._get_coupon_values(partner) + if self.website_id: + res.update(website_id=self.website_id.id) + return res diff --git a/addons/website_sale_loyalty/wizard/loyalty_generate_wizard_views.xml b/addons/website_sale_loyalty/wizard/loyalty_generate_wizard_views.xml new file mode 100644 index 00000000000..6df0890c9ab --- /dev/null +++ b/addons/website_sale_loyalty/wizard/loyalty_generate_wizard_views.xml @@ -0,0 +1,13 @@ + + + + loyalty.generate.wizard.view.form.inherit.website.sale.loyalty + loyalty.generate.wizard + + + + + + + + diff --git a/addons/website_sale_coupon/wizard/sale_coupon_share.py b/addons/website_sale_loyalty/wizard/sale_coupon_share.py similarity index 83% rename from addons/website_sale_coupon/wizard/sale_coupon_share.py rename to addons/website_sale_loyalty/wizard/sale_coupon_share.py index 554d18de39c..808fffedc0d 100644 --- a/addons/website_sale_coupon/wizard/sale_coupon_share.py +++ b/addons/website_sale_loyalty/wizard/sale_coupon_share.py @@ -12,8 +12,7 @@ class SaleCouponShare(models.TransientModel): _description = 'Create links that apply a coupon and redirect to a specific page' def _get_default_website_id(self): - # program_website_id = self.program_website_id - program_website_id = self.env['coupon.program'].browse(self.env.context.get('default_program_id')).website_id + program_website_id = self.env['loyalty.program'].browse(self.env.context.get('default_program_id')).website_id if program_website_id: return program_website_id else: @@ -22,10 +21,11 @@ class SaleCouponShare(models.TransientModel): return len(websites) == 1 and websites or Website website_id = fields.Many2one('website', required=True, default=_get_default_website_id) - coupon_id = fields.Many2one('coupon.coupon', domain="[('program_id', '=', program_id)]") - program_id = fields.Many2one('coupon.program', required=True, domain=[ - '|', ('program_type', '=', 'coupon_program'), - ('promo_code_usage', '=', 'code_needed'), + coupon_id = fields.Many2one('loyalty.card', domain="[('program_id', '=', program_id)]") + program_id = fields.Many2one('loyalty.program', required=True, domain=[ + '|', ('program_type', '=', 'coupons'), # All coupons programs + '|', ('trigger', '=', 'with_code'), # All programs that require a code + ('rule_ids.code', '!=', False), # All programs that can not trigger without a code ]) program_website_id = fields.Many2one('website', string='Program Website', related='program_id.website_id') @@ -35,7 +35,7 @@ class SaleCouponShare(models.TransientModel): @api.constrains('coupon_id', 'program_id') def _check_program(self): - if self.filtered(lambda record: not record.coupon_id and record.program_id.program_type == 'coupon_program'): + if self.filtered(lambda record: not record.coupon_id and record.program_id.program_type == 'coupons'): raise ValidationError(_("A coupon is needed for coupon programs.")) @api.constrains('website_id', 'program_id') @@ -43,10 +43,10 @@ class SaleCouponShare(models.TransientModel): if self.filtered(lambda record: record.program_website_id and record.program_website_id != record.website_id): raise ValidationError(_("The shared website should correspond to the website of the program.")) - @api.depends('coupon_id.code', 'program_id.promo_code') + @api.depends('coupon_id.code', 'program_id.rule_ids.code') def _compute_promo_code(self): for record in self: - record.promo_code = record.coupon_id.code or record.program_id.promo_code + record.promo_code = record.coupon_id.code or record.program_id.rule_ids.filtered('code')[:1].code @api.depends('website_id', 'redirect') @api.depends_context('use_short_link') diff --git a/addons/website_sale_coupon/views/coupon_share_views.xml b/addons/website_sale_loyalty/wizard/sale_coupon_share_views.xml similarity index 96% rename from addons/website_sale_coupon/views/coupon_share_views.xml rename to addons/website_sale_loyalty/wizard/sale_coupon_share_views.xml index 29fdc252305..e75866e90ee 100644 --- a/addons/website_sale_coupon/views/coupon_share_views.xml +++ b/addons/website_sale_loyalty/wizard/sale_coupon_share_views.xml @@ -4,7 +4,7 @@ coupon.share.form coupon.share -
+
diff --git a/addons/website_sale_coupon_delivery/__init__.py b/addons/website_sale_loyalty_delivery/__init__.py similarity index 100% rename from addons/website_sale_coupon_delivery/__init__.py rename to addons/website_sale_loyalty_delivery/__init__.py diff --git a/addons/website_sale_loyalty_delivery/__manifest__.py b/addons/website_sale_loyalty_delivery/__manifest__.py new file mode 100644 index 00000000000..59e0826dffd --- /dev/null +++ b/addons/website_sale_loyalty_delivery/__manifest__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': "Free Delivery with Coupon & Loyalty on eCommerce", + 'summary': """Allows to offer free shippings in loyalty program rewards on eCommerce""", + 'description': """Allows to offer free shippings in loyalty program rewards on eCommerce""", + 'category': 'Website/Website', + 'version': '1.0', + 'depends': ['website_sale_delivery', 'website_sale_loyalty', 'sale_loyalty_delivery'], + 'auto_install': True, + 'assets': { + 'web.assets_frontend': [ + 'website_sale_loyalty_delivery/static/**/*', + ], + }, + 'license': 'LGPL-3', +} diff --git a/addons/website_sale_coupon_delivery/controllers/__init__.py b/addons/website_sale_loyalty_delivery/controllers/__init__.py similarity index 100% rename from addons/website_sale_coupon_delivery/controllers/__init__.py rename to addons/website_sale_loyalty_delivery/controllers/__init__.py diff --git a/addons/website_sale_coupon_delivery/controllers/main.py b/addons/website_sale_loyalty_delivery/controllers/main.py similarity index 88% rename from addons/website_sale_coupon_delivery/controllers/main.py rename to addons/website_sale_loyalty_delivery/controllers/main.py index a5a76412bf9..3d9d217086c 100644 --- a/addons/website_sale_coupon_delivery/controllers/main.py +++ b/addons/website_sale_loyalty_delivery/controllers/main.py @@ -4,17 +4,17 @@ from odoo.addons.website_sale_delivery.controllers.main import WebsiteSaleDelive from odoo.http import request -class WebsiteSaleCouponDelivery(WebsiteSaleDelivery): +class WebsiteSaleLoyaltyDelivery(WebsiteSaleDelivery): @http.route() def update_eshop_carrier(self, **post): Monetary = request.env['ir.qweb.field.monetary'] - result = super(WebsiteSaleCouponDelivery, self).update_eshop_carrier(**post) + result = super().update_eshop_carrier(**post) order = request.website.sale_get_order() free_shipping_lines = None if order: - order.recompute_coupon_lines() + order._update_programs_and_rewards() order.validate_taxes_on_sales_order() free_shipping_lines = order._get_free_shipping_lines() @@ -27,6 +27,7 @@ class WebsiteSaleCouponDelivery(WebsiteSaleDelivery): 'new_amount_tax': Monetary.value_to_html(order.amount_tax, {'display_currency': currency}), 'new_amount_total': Monetary.value_to_html(order.amount_total, {'display_currency': currency}), 'new_amount_order_discounted': Monetary.value_to_html(order.reward_amount - amount_free_shipping, {'display_currency': currency}), + 'new_amount_total_raw': order.amount_total, }) return result @@ -46,4 +47,4 @@ class WebsiteSaleCouponDelivery(WebsiteSaleDelivery): 'new_amount_delivery': Monetary.value_to_html(0.0, {'display_currency': order.currency_id}), 'error_message': None, } - return super(WebsiteSaleCouponDelivery, self).cart_carrier_rate_shipment(carrier_id, **kw) + return super().cart_carrier_rate_shipment(carrier_id, **kw) diff --git a/addons/website_sale_coupon_delivery/static/src/js/website_sale_coupon_delivery.js b/addons/website_sale_loyalty_delivery/static/src/js/website_sale_loyalty_delivery.js similarity index 72% rename from addons/website_sale_coupon_delivery/static/src/js/website_sale_coupon_delivery.js rename to addons/website_sale_loyalty_delivery/static/src/js/website_sale_loyalty_delivery.js index 4bffd181bd4..ad783c2edbd 100644 --- a/addons/website_sale_coupon_delivery/static/src/js/website_sale_coupon_delivery.js +++ b/addons/website_sale_loyalty_delivery/static/src/js/website_sale_loyalty_delivery.js @@ -1,14 +1,12 @@ -odoo.define('website_sale_coupon_delivery.checkout', function (require) { -'use strict'; +/** @odoo-module **/ -var core = require('web.core'); -var publicWidget = require('web.public.widget'); -require('website_sale_delivery.checkout'); +import PublicWidget from 'web.public.widget'; +import { patch } from 'web.utils'; +import core from 'web.core'; -var _t = core._t; - -publicWidget.registry.websiteSaleDelivery.include({ +const _t = core._t; +patch(PublicWidget.registry.websiteSaleDelivery, 'addons/website_sale_loyalty_delivery/statis/src/js/website_sale_loyalty_delivery.js', { //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -16,7 +14,7 @@ publicWidget.registry.websiteSaleDelivery.include({ /** * @override */ - _handleCarrierUpdateResult: function (result) { + _handleCarrierUpdateResult: function (result) { this._super.apply(this, arguments); if (result.new_amount_order_discounted) { // Update discount of the order @@ -35,4 +33,3 @@ publicWidget.registry.websiteSaleDelivery.include({ } }, }); -});