diff --git a/container_meals/README.rst b/container_meals/README.rst new file mode 100644 index 000000000..48d7af4c4 --- /dev/null +++ b/container_meals/README.rst @@ -0,0 +1,118 @@ +=============== +Container Meals +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:186b3af0c6e89a73fbf172e88f7b0811eff840dc945f0b8976b19e990379fdba + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-coopiteasy%2Faddons-lightgray.png?logo=github + :target: https://github.com/coopiteasy/addons/tree/16.0/container_meals + :alt: coopiteasy/addons + +|badge1| |badge2| |badge3| + +This module supports a very specific business model, where customers get meals +delivered to them in glass containers. The containers are lent out and must be +returned. The customers pay a deposit on the glass containers, which they can +have refunded, or which gives them an effective perpetual discount on subsequent +purchases, where they don't have to pay the deposit on the glass containers +again. + +Each meal comes in two glass containers. When two of the same meal are ordered, +those two meals also come in two glass containers total, but the glass +containers may be bigger to account for the increased volume. + +This module: + +- Creates an "Is Meal?" button for meal products, which enables the user to set + the volumes of a single portion for containers 1 and 2. +- Adds a portion size product category. Child portions have 2/3 the volume of + adult portions. This module does not adjust the price of child portions. +- Adds an "Is Container?" button for container products, which enables the user + to set the volume of the container. +- Keeps track of the built up deposit by customers across sales. +- Allows the user to set unreturned containers on sale orders. All containers + are assumed to be returned, and must be manually flagged as unreturned. + Unreturned containers can be found on a customer's contact view and in a + special "Unreturned Containers" menu under Sales. +- Calculates and automatically adds the containers needed to hold the + to-purchase meals on the purchase page, and applies a discount depending on + the customer's current stored deposit. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +For proper function of this module, you need to at least: + +- Create one or more container product templates by enabling "Is Container?" on + a product. +- Set a deposit product in the Sale settings. +- And create meal products, of course. + +Known issues / Roadmap +====================== + +- Support differing units of measurement. +- Better support for changing the deposit product. +- Fix the fact that invoices might use the same deposit multiple times if + created in series before all being payed + +Changelog +========= + +14.0.1.1.0 (2022-05-09) +~~~~~~~~~~~~~~~~~~~~~~~ + +**Bugfixes** + +- The deposit discount can now be greater than the price total of the containers + in an order, but never greater than the total of the order itself. Previously, + only the price total of the containers was given as a discount. (`#224 `_) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Coop IT Easy SC + +Contributors +~~~~~~~~~~~~ + +* `Coop IT Easy SC `_: + + * Carmen Bianca Bakker + +Maintainers +~~~~~~~~~~~ + +This module is part of the `coopiteasy/addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/container_meals/__init__.py b/container_meals/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/container_meals/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/container_meals/__manifest__.py b/container_meals/__manifest__.py new file mode 100644 index 000000000..e801056ec --- /dev/null +++ b/container_meals/__manifest__.py @@ -0,0 +1,29 @@ +# Copyright 2022 Coop IT Easy SC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Container Meals", + "summary": """Deliver meals in containers.""", + "version": "16.0.1.0.0", + "category": "Uncategorized", + "website": "https://github.com/coopiteasy/addons", + "author": "Coop IT Easy SC", + "license": "AGPL-3", + "application": False, + "depends": [ + "partner_manual_rank", # is_customer + "product", + "sale_management", # deposit management + "uom_extra_data", # mL + "website_sale", + ], + "data": [ + "data/portion_attributes.xml", + "views/product_views.xml", + "views/res_config_settings_views.xml", + "views/res_partner_views.xml", + "views/sale_order_line_views.xml", + "views/sale_order_views.xml", + "views/sale_menu.xml", + ], +} diff --git a/container_meals/data/portion_attributes.xml b/container_meals/data/portion_attributes.xml new file mode 100644 index 000000000..73155a5cc --- /dev/null +++ b/container_meals/data/portion_attributes.xml @@ -0,0 +1,24 @@ + + + Portion Size + always + radio + + + + Adult + + 1 + + + Child + + 2 + + diff --git a/container_meals/i18n/container_meals.pot b/container_meals/i18n/container_meals.pot new file mode 100644 index 000000000..67517e66e --- /dev/null +++ b/container_meals/i18n/container_meals.pot @@ -0,0 +1,258 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * container_meals +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: container_meals +#: code:addons/container_meals/models/sale_order.py:0 +#, python-format +msgid "'Not Returned' is only for containers." +msgstr "" + +#. module: container_meals +#: code:addons/container_meals/models/sale_order.py:0 +#, python-format +msgid "'Not Returned' may not be higher than Quantity." +msgstr "" + +#. module: container_meals +#: code:addons/container_meals/models/sale_order.py:0 +#, python-format +msgid "'Not Returned' must be zero or higher." +msgstr "" + +#. module: container_meals +#: model:product.attribute.value,name:container_meals.product_attribute_portion_size_value_adult +msgid "Adult" +msgstr "" + +#. module: container_meals +#: model:product.attribute.value,name:container_meals.product_attribute_portion_size_value_child +msgid "Child" +msgstr "" + +#. module: container_meals +#: model:ir.model,name:container_meals.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: container_meals +#: model:ir.model,name:container_meals.model_res_partner +msgid "Contact" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_res_partner__deposit_related_order_line_ids +#: model:ir.model.fields,field_description:container_meals.field_res_users__deposit_related_order_line_ids +msgid "Container & Deposit Order Lines" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_product_product__container_1_volume +#: model:ir.model.fields,field_description:container_meals.field_product_template__container_1_volume +msgid "Container 1 Volume" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_product_product__container_2_volume +#: model:ir.model.fields,field_description:container_meals.field_product_template__container_2_volume +msgid "Container 2 Volume" +msgstr "" + +#. module: container_meals +#: model_terms:ir.ui.view,arch_db:container_meals.sale_res_config_settings_view_form +msgid "Container Deposit" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_res_config_settings__container_deposit_product_id +msgid "Container Deposit Product" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_product_product__container_uom_id +#: model:ir.model.fields,field_description:container_meals.field_product_template__container_uom_id +msgid "Container Unit of Measure" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_product_product__container_uom_name +#: model:ir.model.fields,field_description:container_meals.field_product_template__container_uom_name +msgid "Container Unit of Measure Name" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_product_product__container_volume +#: model:ir.model.fields,field_description:container_meals.field_product_template__container_volume +msgid "Container Volume" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_sale_order__contains_containers +msgid "Contains Containers" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_res_partner__current_deposit +#: model:ir.model.fields,field_description:container_meals.field_res_users__current_deposit +msgid "Current Container Deposit" +msgstr "" + +#. module: container_meals +#: model_terms:ir.ui.view,arch_db:container_meals.sale_res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:container_meals.view_partner_form +msgid "Deposits" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_ir_config_parameter__display_name +#: model:ir.model.fields,field_description:container_meals.field_product_product__display_name +#: model:ir.model.fields,field_description:container_meals.field_product_template__display_name +#: model:ir.model.fields,field_description:container_meals.field_res_config_settings__display_name +#: model:ir.model.fields,field_description:container_meals.field_res_partner__display_name +#: model:ir.model.fields,field_description:container_meals.field_sale_order__display_name +#: model:ir.model.fields,field_description:container_meals.field_sale_order_line__display_name +msgid "Display Name" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_ir_config_parameter__id +#: model:ir.model.fields,field_description:container_meals.field_product_product__id +#: model:ir.model.fields,field_description:container_meals.field_product_template__id +#: model:ir.model.fields,field_description:container_meals.field_res_config_settings__id +#: model:ir.model.fields,field_description:container_meals.field_res_partner__id +#: model:ir.model.fields,field_description:container_meals.field_sale_order__id +#: model:ir.model.fields,field_description:container_meals.field_sale_order_line__id +msgid "ID" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_product_product__is_container +#: model:ir.model.fields,field_description:container_meals.field_product_template__is_container +#: model:ir.model.fields,field_description:container_meals.field_sale_order_line__is_container +msgid "Is a Container" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_product_product__is_meal +#: model:ir.model.fields,field_description:container_meals.field_product_template__is_meal +msgid "Is a Meal" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_ir_config_parameter____last_update +#: model:ir.model.fields,field_description:container_meals.field_product_product____last_update +#: model:ir.model.fields,field_description:container_meals.field_product_template____last_update +#: model:ir.model.fields,field_description:container_meals.field_res_config_settings____last_update +#: model:ir.model.fields,field_description:container_meals.field_res_partner____last_update +#: model:ir.model.fields,field_description:container_meals.field_sale_order____last_update +#: model:ir.model.fields,field_description:container_meals.field_sale_order_line____last_update +msgid "Last Modified on" +msgstr "" + +#. module: container_meals +#: model_terms:ir.actions.act_window,help:container_meals.action_unreturned_containers +msgid "No orders with unreturned containers found" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_sale_order_line__not_returned +#: model_terms:ir.ui.view,arch_db:container_meals.view_order_form +msgid "Not Returned" +msgstr "" + +#. module: container_meals +#: model:product.attribute,name:container_meals.product_attribute_portion_size +msgid "Portion Size" +msgstr "" + +#. module: container_meals +#: model:ir.model,name:container_meals.model_product_product +msgid "Product" +msgstr "" + +#. module: container_meals +#: model:ir.model,name:container_meals.model_product_template +msgid "Product Template" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,help:container_meals.field_res_config_settings__container_deposit_product_id +#: model_terms:ir.ui.view,arch_db:container_meals.sale_res_config_settings_view_form +msgid "Product used as deposit for containers" +msgstr "" + +#. module: container_meals +#: model_terms:ir.ui.view,arch_db:container_meals.sale_order_line_view_tree_not_returned +msgid "Qty" +msgstr "" + +#. module: container_meals +#: model:ir.model,name:container_meals.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: container_meals +#: model:ir.model,name:container_meals.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: container_meals +#: model_terms:ir.ui.view,arch_db:container_meals.sale_order_line_view_tree_not_returned +msgid "Sales Order Lines" +msgstr "" + +#. module: container_meals +#: model:ir.model,name:container_meals.model_ir_config_parameter +msgid "System Parameter" +msgstr "" + +#. module: container_meals +#: model_terms:ir.ui.view,arch_db:container_meals.sale_order_line_view_tree_not_returned +msgid "Total" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_res_partner__total_container_price +#: model:ir.model.fields,field_description:container_meals.field_res_users__total_container_price +msgid "Total amount spent on containers" +msgstr "" + +#. module: container_meals +#: model:ir.model.fields,field_description:container_meals.field_res_partner__total_deposit_price +#: model:ir.model.fields,field_description:container_meals.field_res_users__total_deposit_price +msgid "Total discount received from deposit products" +msgstr "" + +#. module: container_meals +#: model_terms:ir.ui.view,arch_db:container_meals.sale_order_line_view_tree_not_returned +msgid "Unit Price" +msgstr "" + +#. module: container_meals +#: model_terms:ir.ui.view,arch_db:container_meals.sale_order_line_view_tree_not_returned +msgid "Unit of Measure" +msgstr "" + +#. module: container_meals +#: model:ir.actions.act_window,name:container_meals.action_unreturned_containers +#: model:ir.ui.menu,name:container_meals.unreturned_containers +msgid "Unreturned Containers" +msgstr "" + +#. module: container_meals +#: model_terms:ir.actions.act_window,help:container_meals.action_unreturned_containers +msgid "" +"You can set unreturned containers on the orders themselves,
\n" +" or set them on the customer." +msgstr "" diff --git a/container_meals/models/__init__.py b/container_meals/models/__init__.py new file mode 100644 index 000000000..1c92f8feb --- /dev/null +++ b/container_meals/models/__init__.py @@ -0,0 +1,7 @@ +from . import ir_config_parameter +from . import product_product +from . import product_template +from . import res_config_settings +from . import res_partner +from . import sale_order +from . import sale_order_line diff --git a/container_meals/models/ir_config_parameter.py b/container_meals/models/ir_config_parameter.py new file mode 100644 index 000000000..5be60cf09 --- /dev/null +++ b/container_meals/models/ir_config_parameter.py @@ -0,0 +1,15 @@ +# Copyright 2022 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, models + + +class IrConfigParameter(models.Model): + _inherit = "ir.config_parameter" + + @api.model + def get_container_deposit_product_id(self): + deposit_product_id = self.sudo().get_param( + "container_meals.container_deposit_product_id" + ) + return self.env["product.product"].browse(int(deposit_product_id)) diff --git a/container_meals/models/product_product.py b/container_meals/models/product_product.py new file mode 100644 index 000000000..87a57bf6a --- /dev/null +++ b/container_meals/models/product_product.py @@ -0,0 +1,58 @@ +# Copyright 2022 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +CHILD_PORTION_RATIO = 2 / 3 + + +class Product(models.Model): + _inherit = "product.product" + + # Overrides + container_1_volume = fields.Float( + compute="_compute_container_volume", + digits="Volume", + store=True, + ) + container_2_volume = fields.Float( + compute="_compute_container_volume", + digits="Volume", + store=True, + ) + + @api.depends( + "product_tmpl_id", + "product_tmpl_id.container_1_volume", + "product_tmpl_id.container_2_volume", + "product_template_attribute_value_ids", + ) + def _compute_container_volume(self): + try: + child_portion_attribute_value = self.env.ref( + "container_meals.product_attribute_portion_size_value_child" + ) + except ValueError: + _logger.warning( + "'container_meals.product_attribute_portion_size_value_child'" + " does not exist; container volume calculation may misbehave." + ) + child_portion_attribute_value = self.env["product.attribute.value"] + for product in self: + modifier = 1 + if ( + child_portion_attribute_value + in product.product_template_attribute_value_ids.product_attribute_value_id # noqa: B950 + ): + # TODO: Make this configurable. + modifier = CHILD_PORTION_RATIO + product.container_1_volume = ( + product.product_tmpl_id.container_1_volume * modifier + ) + product.container_2_volume = ( + product.product_tmpl_id.container_2_volume * modifier + ) diff --git a/container_meals/models/product_template.py b/container_meals/models/product_template.py new file mode 100644 index 000000000..8e4858d67 --- /dev/null +++ b/container_meals/models/product_template.py @@ -0,0 +1,34 @@ +# Copyright 2025 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + @api.model + def _get_container_uom_domain(self): + return [("category_id", "=", self.env.ref("uom.product_uom_categ_vol").id)] + + # Container + is_container = fields.Boolean(string="Is a Container", default=False) + container_volume = fields.Float(digits="Volume") + + # Meal + is_meal = fields.Boolean(string="Is a Meal", default=False) + container_1_volume = fields.Float(digits="Volume") + container_2_volume = fields.Float(digits="Volume") + + # Common + container_uom_id = fields.Many2one( + comodel_name="uom.uom", + string="Container Unit of Measure", + domain=lambda self: self._get_container_uom_domain(), + default=lambda self: self.env.ref("uom_extra_data.product_uom_millilitre"), + ) + container_uom_name = fields.Char( + string="Container Unit of Measure Name", + related="container_uom_id.name", + readonly=True, + ) diff --git a/container_meals/models/res_config_settings.py b/container_meals/models/res_config_settings.py new file mode 100644 index 000000000..43614f634 --- /dev/null +++ b/container_meals/models/res_config_settings.py @@ -0,0 +1,16 @@ +# Copyright 2022 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + container_deposit_product_id = fields.Many2one( + "product.product", + string="Container Deposit Product", + domain="[('type', '=', 'service')]", + config_parameter="container_meals.container_deposit_product_id", + help="Product used as deposit for containers", + ) diff --git a/container_meals/models/res_partner.py b/container_meals/models/res_partner.py new file mode 100644 index 000000000..40230775f --- /dev/null +++ b/container_meals/models/res_partner.py @@ -0,0 +1,69 @@ +# Copyright 2022 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class Partner(models.Model): + _inherit = "res.partner" + + total_container_price = fields.Float( + string="Total amount spent on containers", + digits="Product Price", + compute="_compute_containers_deposits", + ) + total_deposit_price = fields.Float( + string="Total discount received from deposit products", + digits="Product Price", + compute="_compute_containers_deposits", + ) + current_deposit = fields.Float( + string="Current Container Deposit", + digits="Product Price", + compute="_compute_containers_deposits", + ) + deposit_related_order_line_ids = fields.One2many( + comodel_name="sale.order.line", + string="Container & Deposit Order Lines", + compute="_compute_deposit_related_order_line_ids", + ) + + @api.depends( + "deposit_related_order_line_ids", "sale_order_ids.order_line.not_returned" + ) + def _compute_containers_deposits(self): + for partner in self: + total_container_price = 0 + total_deposit_price = 0 + for line in partner.deposit_related_order_line_ids: + if line.product_id.is_container: + total_container_price += line.price_unit * ( + line.product_uom_qty - line.not_returned + ) + else: + total_deposit_price += line.price_total + partner.total_container_price = total_container_price + partner.total_deposit_price = total_deposit_price + partner.current_deposit = total_container_price + total_deposit_price + + @api.depends("sale_order_ids", "sale_order_ids.state", "sale_order_ids.order_line") + def _compute_deposit_related_order_line_ids(self): + # TODO: This is not declared in `@api.depends`. If this value were to + # change, the field won't be recomputed. + deposit_product = self.env[ + "ir.config_parameter" + ].get_container_deposit_product_id() + for partner in self: + partner.deposit_related_order_line_ids = ( + self.env["sale.order.line"] + .search( + [ + ("order_id.partner_id", "=", partner.id), + ("order_id.state", "in", ("sale", "done")), + "|", + ("product_id.is_container", "=", True), + ("product_id", "=", deposit_product.id), + ] + ) + .sorted() + ) diff --git a/container_meals/models/sale_order.py b/container_meals/models/sale_order.py new file mode 100644 index 000000000..a47c30a59 --- /dev/null +++ b/container_meals/models/sale_order.py @@ -0,0 +1,173 @@ +# Copyright 2022 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging +from collections import namedtuple + +from odoo import api, fields, models + +ContainerVolumes = namedtuple( + "ContainerVolumes", ["container_1_volume", "container_2_volume"] +) +_EMPTY = ContainerVolumes(0, 0) + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + # Used for views. + contains_containers = fields.Boolean( + compute="_compute_contains_containers", + ) + + def _recompute_prices(self): + # Apply product prices from pricelists and discounts + result = super()._recompute_prices() + # Containers should be added afterward because: + # 1) Otherwise the deposit product price will be reset to his price_unit + # 2) The discounts on the order (if any) will be applied on the + # containers and deposit + self.add_containers() + return result + + @api.depends("order_line", "order_line.product_id") + def _compute_contains_containers(self): + for order in self: + order.contains_containers = bool( + order.order_line.filtered("product_id.is_container") + ) + + def calculate_containers_volumes(self): + """For every product template found in this sale order that + is a meal, return a tuple (combined_container_1_volume, + combined_container_2_volume). + + Example: The sale order contains two adult portions of the salad + template, and one child portion. The volumes for the salad containers + are (900, 600), and (600, 400) for the child portion. This function will + return ``{product.template(salad,): (900*2+600, 600*2+400)}``. + """ + self.ensure_one() + result = dict() + for line in self.order_line: + product_id = line.product_id + product_template = product_id.product_tmpl_id + if product_id.is_meal: + vols = result.setdefault(product_template, _EMPTY) + # TODO: Account for the container's uom, maybe + result[product_template] = ContainerVolumes( + vols.container_1_volume + + (product_id.container_1_volume * line.product_uom_qty), + vols.container_2_volume + + (product_id.container_2_volume * line.product_uom_qty), + ) + return result + + def find_containers_for_volume(self, volume): + """Given a volume, find a list of containers that will hold it. + + Important to note is that the list may: + + - include 0 items (volume is (sub-)zero) + - include 1 item (volume fits into one container) + - include 2 or more items (need more containers to hold the total + volume) + + The algorithm used below is naive. Find the smallest container that will + hold the volume. If the candidate container is smaller than the volume, + add another container repeating the algorithm. + """ + self.ensure_one() + + result = [] + if volume <= 0: + return result + + containers = ( + self.env["product.template"] + .search([("is_container", "=", True)]) + .sorted(key="container_volume") + ) + + biggest_volume = containers[-1].container_volume + quotient, remainder = divmod(volume, biggest_volume) + + result.extend(int(quotient) * [containers[-1].product_variant_id]) + + if remainder: + for container in containers: + if container.container_volume >= remainder: + result.append(container.product_variant_id) + break + + return result + + def add_containers(self): + """Execute all the logic to add the appropriate amount of containers + to this sale order. + + Pre-existing containers in the sale order will be cleared for new ones. + """ + self.ensure_one() + self._remove_containers() + + template_volumes_dict = self.calculate_containers_volumes() + containers_to_add = {} # container: amount + total_container_price = 0 + + for volumes in template_volumes_dict.values(): + for volume in volumes: + containers = self.find_containers_for_volume(volume) + for container in containers: + containers_to_add[container] = 1 + containers_to_add.get( + container, 0 + ) + + for container, amount in containers_to_add.items(): + values = { + "order_id": self.id, + "name": container.name, + "product_uom_qty": amount, + "product_uom": container.uom_id.id, + "product_id": container.id, + "price_unit": container.lst_price, + } + line = self.env["sale.order.line"].sudo().create(values) + total_container_price += line.price_total + + discount = min(self.amount_total, self.partner_id.current_deposit) + deposit_product = self.env[ + "ir.config_parameter" + ].get_container_deposit_product_id() + if discount > 0 and deposit_product: + values = { + "order_id": self.id, + "name": deposit_product.name, + "product_uom_qty": 1, + "product_uom": deposit_product.uom_id.id, + "product_id": deposit_product.id, + "price_unit": -discount, + } + self.env["sale.order.line"].sudo().create(values) + + def _cart_update( + self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs + ): + self._remove_containers() + + values = super()._cart_update(product_id, line_id, add_qty, set_qty, **kwargs) + return values + + def _remove_containers(self): + self.ensure_one() + deposit_product = self.env[ + "ir.config_parameter" + ].get_container_deposit_product_id() + + lines_to_remove = self.order_line.filtered( + lambda line: line.product_id.is_container + or line.product_id == deposit_product + ) + lines_to_remove.unlink() diff --git a/container_meals/models/sale_order_line.py b/container_meals/models/sale_order_line.py new file mode 100644 index 000000000..c0d811185 --- /dev/null +++ b/container_meals/models/sale_order_line.py @@ -0,0 +1,25 @@ +# Copyright 2025 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.translate import _ + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + not_returned = fields.Integer(default=0) + is_container = fields.Boolean(related="product_id.is_container") + + @api.constrains("not_returned", "product_uom_qty") + def _check_not_returned(self): + for line in self: + if line.not_returned != 0 and not line.product_id.is_container: + raise ValidationError(_("'Not Returned' is only for containers.")) + elif line.not_returned < 0: + raise ValidationError(_("'Not Returned' must be zero or higher.")) + elif line.not_returned > line.product_uom_qty: + raise ValidationError( + _("'Not Returned' may not be higher than Quantity.") + ) diff --git a/container_meals/readme/CONTRIBUTORS.rst b/container_meals/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..d64451edc --- /dev/null +++ b/container_meals/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Coop IT Easy SC `_: + + * Carmen Bianca Bakker diff --git a/container_meals/readme/DESCRIPTION.rst b/container_meals/readme/DESCRIPTION.rst new file mode 100644 index 000000000..61fe9324b --- /dev/null +++ b/container_meals/readme/DESCRIPTION.rst @@ -0,0 +1,27 @@ +This module supports a very specific business model, where customers get meals +delivered to them in glass containers. The containers are lent out and must be +returned. The customers pay a deposit on the glass containers, which they can +have refunded, or which gives them an effective perpetual discount on subsequent +purchases, where they don't have to pay the deposit on the glass containers +again. + +Each meal comes in two glass containers. When two of the same meal are ordered, +those two meals also come in two glass containers total, but the glass +containers may be bigger to account for the increased volume. + +This module: + +- Creates an "Is Meal?" button for meal products, which enables the user to set + the volumes of a single portion for containers 1 and 2. +- Adds a portion size product category. Child portions have 2/3 the volume of + adult portions. This module does not adjust the price of child portions. +- Adds an "Is Container?" button for container products, which enables the user + to set the volume of the container. +- Keeps track of the built up deposit by customers across sales. +- Allows the user to set unreturned containers on sale orders. All containers + are assumed to be returned, and must be manually flagged as unreturned. + Unreturned containers can be found on a customer's contact view and in a + special "Unreturned Containers" menu under Sales. +- Calculates and automatically adds the containers needed to hold the + to-purchase meals on the purchase page, and applies a discount depending on + the customer's current stored deposit. diff --git a/container_meals/readme/HISTORY.rst b/container_meals/readme/HISTORY.rst new file mode 100644 index 000000000..080a1a63c --- /dev/null +++ b/container_meals/readme/HISTORY.rst @@ -0,0 +1,8 @@ +14.0.1.1.0 (2022-05-09) +~~~~~~~~~~~~~~~~~~~~~~~ + +**Bugfixes** + +- The deposit discount can now be greater than the price total of the containers + in an order, but never greater than the total of the order itself. Previously, + only the price total of the containers was given as a discount. (`#224 `_) diff --git a/container_meals/readme/ROADMAP.rst b/container_meals/readme/ROADMAP.rst new file mode 100644 index 000000000..b9951ab7b --- /dev/null +++ b/container_meals/readme/ROADMAP.rst @@ -0,0 +1,4 @@ +- Support differing units of measurement. +- Better support for changing the deposit product. +- Fix the fact that invoices might use the same deposit multiple times if + created in series before all being payed diff --git a/container_meals/readme/USAGE.rst b/container_meals/readme/USAGE.rst new file mode 100644 index 000000000..3c73e3645 --- /dev/null +++ b/container_meals/readme/USAGE.rst @@ -0,0 +1,6 @@ +For proper function of this module, you need to at least: + +- Create one or more container product templates by enabling "Is Container?" on + a product. +- Set a deposit product in the Sale settings. +- And create meal products, of course. diff --git a/container_meals/static/description/index.html b/container_meals/static/description/index.html new file mode 100644 index 000000000..f8caad6d2 --- /dev/null +++ b/container_meals/static/description/index.html @@ -0,0 +1,481 @@ + + + + + +Container Meals + + + +
+

Container Meals

+ + +

Beta License: AGPL-3 coopiteasy/addons

+

This module supports a very specific business model, where customers get meals +delivered to them in glass containers. The containers are lent out and must be +returned. The customers pay a deposit on the glass containers, which they can +have refunded, or which gives them an effective perpetual discount on subsequent +purchases, where they don’t have to pay the deposit on the glass containers +again.

+

Each meal comes in two glass containers. When two of the same meal are ordered, +those two meals also come in two glass containers total, but the glass +containers may be bigger to account for the increased volume.

+

This module:

+
    +
  • Creates an “Is Meal?” button for meal products, which enables the user to set +the volumes of a single portion for containers 1 and 2.
  • +
  • Adds a portion size product category. Child portions have 2/3 the volume of +adult portions. This module does not adjust the price of child portions.
  • +
  • Adds an “Is Container?” button for container products, which enables the user +to set the volume of the container.
  • +
  • Keeps track of the built up deposit by customers across sales.
  • +
  • Allows the user to set unreturned containers on sale orders. All containers +are assumed to be returned, and must be manually flagged as unreturned. +Unreturned containers can be found on a customer’s contact view and in a +special “Unreturned Containers” menu under Sales.
  • +
  • Calculates and automatically adds the containers needed to hold the +to-purchase meals on the purchase page, and applies a discount depending on +the customer’s current stored deposit.
  • +
+

Table of contents

+ +
+

Usage

+

For proper function of this module, you need to at least:

+
    +
  • Create one or more container product templates by enabling “Is Container?” on +a product.
  • +
  • Set a deposit product in the Sale settings.
  • +
  • And create meal products, of course.
  • +
+
+
+

Known issues / Roadmap

+
    +
  • Support differing units of measurement.
  • +
  • Better support for changing the deposit product.
  • +
  • Fix the fact that invoices might use the same deposit multiple times if +created in series before all being payed
  • +
+
+
+

Changelog

+
+

14.0.1.1.0 (2022-05-09)

+

Bugfixes

+
    +
  • The deposit discount can now be greater than the price total of the containers +in an order, but never greater than the total of the order itself. Previously, +only the price total of the containers was given as a discount. (#224)
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Coop IT Easy SC
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the coopiteasy/addons project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/container_meals/tests/__init__.py b/container_meals/tests/__init__.py new file mode 100644 index 000000000..a714a4308 --- /dev/null +++ b/container_meals/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_deposit +from . import test_product +from . import test_sale_order diff --git a/container_meals/tests/common.py b/container_meals/tests/common.py new file mode 100644 index 000000000..c8c34bdd7 --- /dev/null +++ b/container_meals/tests/common.py @@ -0,0 +1,142 @@ +# Copyright 2022 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.tests.common import TransactionCase + +# TODO: Deep-inheriting TestCommon is probably an anti-pattern. Instead consider +# defining several setup methods in the base class, and in the setup of child +# classes, call only the ones that are really needed (composition). + + +class TestCommon(TransactionCase): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + + cls.attribute_portion_size = cls.env.ref( + "container_meals.product_attribute_portion_size" + ) + cls.attribute_portion_size_adult = cls.env.ref( + "container_meals.product_attribute_portion_size_value_adult" + ) + cls.attribute_portion_size_child = cls.env.ref( + "container_meals.product_attribute_portion_size_value_child" + ) + + # fmt: off + cls.salad_template = cls.env["product.template"].create({ + "name": "Salad", + "type": "consu", + "list_price": 12, + "is_meal": True, + "container_1_volume": 600, + "container_2_volume": 300, + "attribute_line_ids": [( + 0, + 0, + { + "attribute_id": cls.attribute_portion_size.id, + "value_ids": [( + 6, + 0, + [ + cls.attribute_portion_size_adult.id, + cls.attribute_portion_size_child.id, + ], + )], + }, + )], + }) + # fmt: on + + products = cls.env["product.product"].search( + [ + ("product_tmpl_id", "=", cls.salad_template.id), + ] + ) + + # This is hacky and stupid, and there is doubtlessly a + # better way. + for product in products: + # There should only be one value, but looping anyway. + for value in product.product_template_attribute_value_ids: + if value.name == cls.attribute_portion_size_adult.name: + cls.salad_product_adult = product + else: + cls.salad_product_child = product + + cls.container_volumes = (400, 600, 1000, 1200, 1800, 3100) + cls.containers = { + key: cls.env["product.template"].create( + { + "name": f"Container {key} mL", + "list_price": key / 100, + "is_container": True, + "container_volume": key, + "taxes_id": None, + } + ) + for key in cls.container_volumes + } + + container_deposit_product_template = cls.env["product.template"].create( + { + "name": "Deposit", + "list_price": 0, + "taxes_id": None, + } + ) + cls.container_deposit_product = ( + container_deposit_product_template.product_variant_id + ) + cls.env["ir.config_parameter"].sudo().set_param( + "container_meals.container_deposit_product_id", + cls.container_deposit_product.id, + ) + + +class TestCommonSaleOrder(TestCommon): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + + cls.pricelist = cls.env["product.pricelist"].create( + {"name": "Default Pricelist"} + ) + cls.partner = cls.env["res.partner"].create( + { + "name": "Customer", + } + ) + + cls.sale_order = cls.env["sale.order"].create( + { + "name": "Sale", + "partner_id": cls.partner.id, + "partner_invoice_id": cls.partner.id, + "partner_shipping_id": cls.partner.id, + "pricelist_id": cls.pricelist.id, + } + ) + + +class TestCommonDeposit(TestCommonSaleOrder): + @classmethod + def setUpClass(cls, *args, **kwargs): + super().setUpClass(*args, **kwargs) + + cls.previous_sale_order = cls.env["sale.order"].create( + { + "name": "Previous Sale", + "partner_id": cls.partner.id, + "partner_invoice_id": cls.partner.id, + "partner_shipping_id": cls.partner.id, + "pricelist_id": cls.pricelist.id, + } + ) + + cls.previous_sale_order._cart_update( + product_id=cls.salad_product_adult.id, line_id=None, add_qty=1, set_qty=0 + ) + cls.previous_sale_order.add_containers() + cls.previous_sale_order.action_done() diff --git a/container_meals/tests/test_deposit.py b/container_meals/tests/test_deposit.py new file mode 100644 index 000000000..303b93acf --- /dev/null +++ b/container_meals/tests/test_deposit.py @@ -0,0 +1,166 @@ +# Copyright 2022 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.exceptions import ValidationError + +from . import common + + +class TestDeposit(common.TestCommonDeposit): + def test_buy_same_meal(self): + """When buying an identical meal as the previous one, the deposit covers + the combined price of the containers. + """ + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=1, set_qty=0 + ) + self.sale_order.add_containers() + + # Meal, two containers, deposit + self.assertEqual(len(self.sale_order.order_line), 4) + deposit_line = self.sale_order.order_line.filtered( + lambda line: line.product_id == self.container_deposit_product + ) + self.assertAlmostEqual(deposit_line.price_total, -self.partner.current_deposit) + + def test_buy_more_expensive_meal(self): + """When buying a more expensive meal than the previous one, the deposit + only covers as much as the previous meal, and is then raised afterwards. + """ + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=4, set_qty=0 + ) + self.sale_order.add_containers() + + # Meal, two containers, deposit + self.assertEqual(len(self.sale_order.order_line), 4) + deposit_line = self.sale_order.order_line.filtered( + lambda line: line.product_id == self.container_deposit_product + ) + self.assertAlmostEqual( + abs(deposit_line.price_total), self.partner.current_deposit + ) + + previous_deposit = self.partner.current_deposit + container_lines = self.sale_order.order_line.filtered("product_id.is_container") + container_price = sum(line.price_total for line in container_lines) + self.sale_order.action_done() + + self.assertGreater(self.partner.current_deposit, previous_deposit) + self.assertAlmostEqual(self.partner.current_deposit, container_price) + + def test_buy_less_expensive_meal(self): + """When buying a less expensive meal than the previous one, give the + entire deposit as discount. The deposit goes down in this way. + """ + self.salad_template.container_2_volume = 0 + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=1, set_qty=0 + ) + self.sale_order.add_containers() + + # Meal, one container, deposit + self.assertEqual(len(self.sale_order.order_line), 3) + deposit_line = self.sale_order.order_line.filtered( + lambda line: line.product_id == self.container_deposit_product + ) + container_lines = self.sale_order.order_line.filtered("product_id.is_container") + container_price = sum(line.price_total for line in container_lines) + other_lines = self.sale_order.order_line.filtered( + lambda line: line != deposit_line + ) + self.assertGreater(abs(deposit_line.price_total), container_price) + self.assertAlmostEqual( + self.sale_order.amount_total, + sum(line.price_total for line in other_lines) + - abs(deposit_line.price_total), + ) + + previous_deposit = self.partner.current_deposit + self.sale_order.action_done() + + self.assertLess(self.partner.current_deposit, previous_deposit) + self.assertAlmostEqual( + self.partner.current_deposit + + (abs(deposit_line.price_total) - container_price), + previous_deposit, + ) + + def test_massive_deposit_small_order(self): + """If a customer has a huge deposit, don't give a discount greater than + the total price of the order. + """ + big_sale_order = self.env["sale.order"].create( + { + "name": "Big Sale", + "partner_id": self.partner.id, + "partner_invoice_id": self.partner.id, + "partner_shipping_id": self.partner.id, + "pricelist_id": self.pricelist.id, + } + ) + big_sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=100, set_qty=0 + ) + big_sale_order.add_containers() + big_sale_order.action_done() + + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=1, set_qty=0 + ) + self.sale_order.add_containers() + + self.assertAlmostEqual(self.sale_order.amount_total, 0) + + deposit_line = self.sale_order.order_line.filtered( + lambda line: line.product_id == self.container_deposit_product + ) + other_lines = self.sale_order.order_line.filtered( + lambda line: line != deposit_line + ) + cum_price_other_lines = sum(line.price_total for line in other_lines) + + self.assertAlmostEqual( + abs(deposit_line.price_total), abs(cum_price_other_lines) + ) + + def test_no_deposit_yet(self): + """If the customer has no deposit yet, don't add a deposit line to the + sale order. + """ + self.previous_sale_order.action_cancel() + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=1, set_qty=0 + ) + self.sale_order.add_containers() + + # Meal, two containers + self.assertEqual(len(self.sale_order.order_line), 3) + deposit_line = self.sale_order.order_line.filtered( + lambda line: line.product_id == self.container_deposit_product + ) + self.assertFalse(deposit_line) + + def test_container_not_returned(self): + """If a container was not returned, dock it from the customer's deposit.""" + previous_deposit = self.partner.current_deposit + container_line = self.previous_sale_order.order_line.filtered( + lambda line: line.product_id == self.containers[400].product_variant_id + ) + container_line.not_returned = 1 + + self.assertLess(self.partner.current_deposit, previous_deposit) + + def test_container_not_returned_invalid_values(self): + """Test the constraints of not_returned.""" + container_line = self.previous_sale_order.order_line.filtered( + lambda line: line.product_id == self.containers[400].product_variant_id + ) + meal_line = self.previous_sale_order.order_line.filtered("product_id.is_meal") + + with self.assertRaises(ValidationError): + container_line.not_returned = -1 + with self.assertRaises(ValidationError): + container_line.not_returned = 2 + with self.assertRaises(ValidationError): + meal_line.not_returned = 1 diff --git a/container_meals/tests/test_product.py b/container_meals/tests/test_product.py new file mode 100644 index 000000000..2738f8406 --- /dev/null +++ b/container_meals/tests/test_product.py @@ -0,0 +1,33 @@ +# Copyright 2022 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from ..models.product import CHILD_PORTION_RATIO +from . import common + + +class TestProduct(common.TestCommon): + def test_container_volume_smaller_for_children(self): + """A child portion's container volume is not as big as an + adult portion. + """ + self.assertAlmostEqual( + self.salad_template.container_1_volume * CHILD_PORTION_RATIO, + self.salad_product_child.container_1_volume, + ) + self.assertAlmostEqual( + self.salad_template.container_2_volume * CHILD_PORTION_RATIO, + self.salad_product_child.container_2_volume, + ) + + def test_container_volume_identical_for_adults(self): + """Adult portions are equally as big as declared in the + template. + """ + self.assertAlmostEqual( + self.salad_template.container_1_volume, + self.salad_product_adult.container_1_volume, + ) + self.assertAlmostEqual( + self.salad_template.container_2_volume, + self.salad_product_adult.container_2_volume, + ) diff --git a/container_meals/tests/test_sale_order.py b/container_meals/tests/test_sale_order.py new file mode 100644 index 000000000..15e7b4556 --- /dev/null +++ b/container_meals/tests/test_sale_order.py @@ -0,0 +1,238 @@ +# Copyright 2022 Coop IT Easy SC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import common + + +class TestSaleOrder(common.TestCommonSaleOrder): + def test_double_volume_two_meals(self): + """When two meals are added, the required volume doubles.""" + self.env["sale.order.line"].create( + { + "name": self.salad_product_adult.name, + "product_id": self.salad_product_adult.id, + "product_uom_qty": 2, + "product_uom": self.salad_product_adult.uom_id.id, + "price_unit": self.salad_product_adult.list_price, + "order_id": self.sale_order.id, + "tax_id": False, + } + ) + result = self.sale_order.calculate_containers_volumes() + + # One product template. + self.assertEqual(len(result), 1) + + container_volumes = result[self.salad_template] + self.assertEqual( + container_volumes[0], self.salad_product_adult.container_1_volume * 2 + ) + self.assertEqual( + container_volumes[1], self.salad_product_adult.container_2_volume * 2 + ) + + def test_adult_and_child_portion(self): + """When an adult and child portion are added, the required volume + (almost) doubles. + """ + self.env["sale.order.line"].create( + { + "name": self.salad_product_adult.name, + "product_id": self.salad_product_adult.id, + "product_uom_qty": 1, + "product_uom": self.salad_product_adult.uom_id.id, + "price_unit": self.salad_product_adult.list_price, + "order_id": self.sale_order.id, + "tax_id": False, + } + ) + self.env["sale.order.line"].create( + { + "name": self.salad_product_child.name, + "product_id": self.salad_product_child.id, + "product_uom_qty": 1, + "product_uom": self.salad_product_child.uom_id.id, + "price_unit": self.salad_product_child.list_price, + "order_id": self.sale_order.id, + "tax_id": False, + } + ) + + result = self.sale_order.calculate_containers_volumes() + + # One product template. + self.assertEqual(len(result), 1) + + container_volumes = result[self.salad_template] + self.assertEqual( + container_volumes[0], + self.salad_product_adult.container_1_volume + + self.salad_product_child.container_1_volume, + ) + self.assertEqual( + container_volumes[1], + self.salad_product_adult.container_2_volume + + self.salad_product_child.container_2_volume, + ) + + def test_not_a_meal(self): + """When the order doesn't contain meals, don't calculate + volumes. + """ + desk = self.env.ref("product.product_product_4") + self.env["sale.order.line"].create( + { + "name": desk.name, + "product_id": desk.id, + "product_uom_qty": 2, + "product_uom": desk.uom_id.id, + "price_unit": desk.list_price, + "order_id": self.sale_order.id, + "tax_id": False, + } + ) + + result = self.sale_order.calculate_containers_volumes() + + self.assertFalse(result) + + def test_find_containers(self): + """Find containers that will fit the required volumes.""" + self.env["sale.order.line"].create( + { + "name": self.salad_product_adult.name, + "product_id": self.salad_product_adult.id, + "product_uom_qty": 2, + "product_uom": self.salad_product_adult.uom_id.id, + "price_unit": self.salad_product_adult.list_price, + "order_id": self.sale_order.id, + "tax_id": False, + } + ) + volumes = self.sale_order.calculate_containers_volumes()[self.salad_template] + result_1 = self.sale_order.find_containers_for_volume(volumes[0]) + result_2 = self.sale_order.find_containers_for_volume(volumes[1]) + + self.assertEqual(len(result_1), 1) + self.assertEqual(result_1, [self.containers[1200].product_variant_id]) + self.assertEqual(len(result_2), 1) + self.assertEqual(result_2, [self.containers[600].product_variant_id]) + + def test_find_containers_container_is_zero(self): + """When the value of container_2_volume is 0, don't add two containers.""" + self.salad_template.container_2_volume = 0 + self.env["sale.order.line"].create( + { + "name": self.salad_product_adult.name, + "product_id": self.salad_product_adult.id, + "product_uom_qty": 1, + "product_uom": self.salad_product_adult.uom_id.id, + "price_unit": self.salad_product_adult.list_price, + "order_id": self.sale_order.id, + "tax_id": False, + } + ) + volumes = self.sale_order.calculate_containers_volumes()[self.salad_template] + result_1 = self.sale_order.find_containers_for_volume(volumes[0]) + result_2 = self.sale_order.find_containers_for_volume(volumes[1]) + + self.assertEqual(len(result_1), 1) + self.assertEqual(len(result_2), 0) + + def test_add_containers(self): + """When adding containers to a cart that contains a single item, expect + two correctly sized containers.""" + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=1, set_qty=0 + ) + self.sale_order.add_containers() + + self.assertEqual(len(self.sale_order.order_line), 3) + + products = [line.product_id for line in self.sale_order.order_line] + self.assertIn(self.salad_product_adult, products) + self.assertIn(self.containers[600].product_variant_id, products) + self.assertIn(self.containers[400].product_variant_id, products) + + lines = self.sale_order.order_line.filtered( + lambda line: line.product_id.is_container + ) + for line in lines: + self.assertEqual(line.product_uom_qty, 1) + + def test_add_containers_twice(self): + """When doing add_containers() twice, don't end up with four containers.""" + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=1, set_qty=0 + ) + self.sale_order.add_containers() + self.sale_order.add_containers() + + self.assertEqual(len(self.sale_order.order_line), 3) + lines = self.sale_order.order_line.filtered( + lambda line: line.product_id.is_container + ) + for line in lines: + self.assertEqual(line.product_uom_qty, 1) + + def test_add_to_cart_twice(self): + """When adding a single meal twice, don't end up with four containers.""" + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=1, set_qty=0 + ) + salad_line = self.sale_order.order_line[0] + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, + line_id=salad_line.id, + add_qty=1, + set_qty=0, + ) + self.sale_order.add_containers() + + self.assertEqual(len(self.sale_order.order_line), 3) + + products = [line.product_id for line in self.sale_order.order_line] + self.assertIn(self.salad_product_adult, products) + self.assertIn(self.containers[1200].product_variant_id, products) + self.assertIn(self.containers[600].product_variant_id, products) + + lines = self.sale_order.order_line.filtered( + lambda line: line.product_id.is_container + ) + for line in lines: + self.assertEqual(line.product_uom_qty, 1) + + def test_remove_containers_after_adding_to_cart(self): + """When a new item is added to the cart, remove all containers.""" + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=1, set_qty=0 + ) + salad_line = self.sale_order.order_line[0] + self.sale_order.add_containers() + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, + line_id=salad_line.id, + add_qty=1, + set_qty=0, + ) + + self.assertEqual(len(self.sale_order.order_line), 1) + + def test_rounding_error_biggest_container(self): + """When adding two meals that each have half the volume of the biggest + container, only add a single container. + """ + self.salad_template.container_1_volume = 3100 / 2 + self.salad_template.container_2_volume = 0 + + self.sale_order._cart_update( + product_id=self.salad_product_adult.id, line_id=None, add_qty=2, set_qty=0 + ) + self.sale_order.add_containers() + + self.assertEqual(len(self.sale_order.order_line), 2) + container_line = self.sale_order.order_line.filtered( + lambda line: line.product_id == self.containers[3100].product_variant_id + ) + self.assertTrue(container_line) + self.assertEqual(container_line.product_uom_qty, 1) diff --git a/container_meals/views/product_views.xml b/container_meals/views/product_views.xml new file mode 100644 index 000000000..ba31d0eb3 --- /dev/null +++ b/container_meals/views/product_views.xml @@ -0,0 +1,61 @@ + + + + + container_meals.product_template_only_form_view + product.template + + + +
+ +
+
+ +
+
+
+
+ + + container_meals.product_template_form_view + product.template + + + + + + + + + + + + + + + +
diff --git a/container_meals/views/res_config_settings_views.xml b/container_meals/views/res_config_settings_views.xml new file mode 100644 index 000000000..e9c894821 --- /dev/null +++ b/container_meals/views/res_config_settings_views.xml @@ -0,0 +1,37 @@ + + + + container_meals.sale_res_config_settings_view_form + res.config.settings + + + +

Deposits

+
+
+
+
+
+
+
+
+
+
diff --git a/container_meals/views/res_partner_views.xml b/container_meals/views/res_partner_views.xml new file mode 100644 index 000000000..eed17975e --- /dev/null +++ b/container_meals/views/res_partner_views.xml @@ -0,0 +1,36 @@ + + + + container_meals.view_partner_form + res.partner + + + + + + + + + + + + + + + + + + + + + + + diff --git a/container_meals/views/sale_menu.xml b/container_meals/views/sale_menu.xml new file mode 100644 index 000000000..b6556d2d6 --- /dev/null +++ b/container_meals/views/sale_menu.xml @@ -0,0 +1,30 @@ + + + + Unreturned Containers + sale.order.line + tree,form + + [('product_id.is_container', '=', True), ('not_returned', '>', 0)] + + +

+ No orders with unreturned containers found +

+ You can set unreturned containers on the orders themselves,
+ or set them on the customer. +

+
+
+ + +
diff --git a/container_meals/views/sale_order_line_views.xml b/container_meals/views/sale_order_line_views.xml new file mode 100644 index 000000000..8fc878c64 --- /dev/null +++ b/container_meals/views/sale_order_line_views.xml @@ -0,0 +1,68 @@ + + + + + container_meals.view_order_line_tree + sale.order.line + + + + + + + + + + + container_meals.sale_order_line_view_tree_not_returned + sale.order.line + 100 + + + + + + + + + + + + + + + + + + container_meals.sale_order_line_view_form_readonly + sale.order.line + + + + + + + + + + diff --git a/container_meals/views/sale_order_views.xml b/container_meals/views/sale_order_views.xml new file mode 100644 index 000000000..fa36c08ec --- /dev/null +++ b/container_meals/views/sale_order_views.xml @@ -0,0 +1,43 @@ + + + + container_meals.view_order_form + sale.order + + + + + + + + + + + + + + + diff --git a/setup/container_meals/odoo/addons/container_meals b/setup/container_meals/odoo/addons/container_meals new file mode 120000 index 000000000..15ea1be30 --- /dev/null +++ b/setup/container_meals/odoo/addons/container_meals @@ -0,0 +1 @@ +../../../../container_meals \ No newline at end of file diff --git a/setup/container_meals/setup.py b/setup/container_meals/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/container_meals/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)