diff --git a/pos_scrap_order/README.rst b/pos_scrap_order/README.rst new file mode 100644 index 000000000..66343fcff --- /dev/null +++ b/pos_scrap_order/README.rst @@ -0,0 +1,121 @@ +=============== +Pos Scrap Order +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:7d2e581a80faad40e9ec1dacd70ad8c5b4bdd0f53e02bdea65d606776a888702 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-AwesomeFoodCoops%2Fodoo--production-lightgray.png?logo=github + :target: https://github.com/AwesomeFoodCoops/odoo-production/tree/18.0/pos_scrap_order + :alt: AwesomeFoodCoops/odoo-production + +|badge1| |badge2| |badge3| + +This module allows cashiers to create scrap orders directly from the +Point of Sale screen, without leaving the POS session. + +Key features: + +- **Scrap from POS**: create scrap orders for all products in the + current order with one click. +- **Scrap list**: view the 50 most recent scrap orders created from POS + sessions. +- **Configurable validation mode**: choose how scrap orders are + validated depending on available stock. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to **Point of Sale → Configuration → Settings** and locate the +**Scrap Order** section. + +Set the **Scrap Order Option** field to one of the following values: + ++----------------------------------+----------------------------------+ +| Option | Behaviour | ++==================================+==================================+ +| **No Scrap Order** | Scrap buttons are hidden in POS. | +| | No scrap order can be created. | ++----------------------------------+----------------------------------+ +| **Create and validate for | Creates scrap orders and | +| products with stock only** | validates them only if the | +| *(default)* | product has sufficient on-hand | +| | quantity. | ++----------------------------------+----------------------------------+ +| **Always create, validate if has | Always creates scrap orders; | +| stock** | validates them only when stock | +| | is available. | ++----------------------------------+----------------------------------+ +| **Always create, validate | Always creates and validates | +| regardless of stock** | scrap orders, even if on-hand | +| | quantity is zero or negative. | ++----------------------------------+----------------------------------+ + +Usage +===== + +**Creating a scrap order from POS** + +1. Add at least one product to the current order. +2. Click the **Actions** button (bottom-right area of the POS screen). +3. In the popup, click **Make Scrap Order**. +4. Review the list of products and quantities to be scrapped. +5. Click **Create Scrap Order** to confirm. +6. A confirmation dialog will appear. On success, the screen returns to + the product screen. + +**Viewing recent scrap orders** + +1. Click the **Actions** button. +2. Click **Scrap List** to display the 50 most recent scrap orders + originating from POS sessions. +3. Click **Make Scrap Order** to navigate to the scrap creation screen. + +**Error cases** + +- If the order is empty, an error dialog will appear. +- If the option is set to *Create and validate for products with stock + only* and a product has insufficient stock, the scrap operation is + rolled back and an error message is displayed. + +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 +------- + +* Trobz +* La Louve + +Maintainers +----------- + +This module is part of the `AwesomeFoodCoops/odoo-production `_ project on GitHub. + +You are welcome to contribute. diff --git a/pos_scrap_order/__init__.py b/pos_scrap_order/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/pos_scrap_order/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/pos_scrap_order/__manifest__.py b/pos_scrap_order/__manifest__.py new file mode 100644 index 000000000..63392861b --- /dev/null +++ b/pos_scrap_order/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright (C) Nguyen Minh Chien (chien@trobz.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Pos Scrap Order", + "version": "18.0.1.0.0", + "category": "Point Of Sale", + "summary": """Create scrap order from POS screen""", + "author": "Trobz, La Louve", + "website": "https://github.com/AwesomeFoodCoops/odoo-production", + "license": "AGPL-3", + "depends": ["point_of_sale", "stock"], + "data": [ + "security/ir.model.access.csv", + "views/pos_config.xml", + ], + "installable": True, + "assets": { + "point_of_sale._assets_pos": [ + "pos_scrap_order/static/src/css/**/*", + "pos_scrap_order/static/src/app/**/*", + ], + }, +} diff --git a/pos_scrap_order/i18n/fr.po b/pos_scrap_order/i18n/fr.po new file mode 100644 index 000000000..76b5c1d72 --- /dev/null +++ b/pos_scrap_order/i18n/fr.po @@ -0,0 +1,244 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_scrap_order +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-21 10:50+0000\n" +"PO-Revision-Date: 2026-04-21 10:50+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_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.xml:0 +msgid "ATTENTION" +msgstr "ATTENTION" + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "Access Error!" +msgstr "Erreur d'accès!" + +#. module: pos_scrap_order +#: model:ir.model.fields.selection,name:pos_scrap_order.selection__pos_config__scrap_order_option__always +msgid "Always create Scrap Order, validate if has stock" +msgstr "Toujours créer la mise au rebut, valider seulement si le produit est en stock" + +#. module: pos_scrap_order +#: model:ir.model.fields.selection,name:pos_scrap_order.selection__pos_config__scrap_order_option__force +msgid "Always create Scrap Order, validate regardless of no stock" +msgstr "Toujours créer la mise au rebut, valider même si le produit n'est pas en stock" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml:0 +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.xml:0 +msgid "Back" +msgstr "Retour" + +#. module: pos_scrap_order +#: model_terms:ir.ui.view,arch_db:pos_scrap_order.pos_config_view_form_inherit +msgid "Configure how scrap orders are created from the POS screen" +msgstr "Configurer comment les ordres de rebut sont créés depuis l'écran du point de vente" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml:0 +msgid "Create Date" +msgstr "Date de création" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.xml:0 +msgid "Create Scrap Order" +msgstr "Créer la mise au rebut" + +#. module: pos_scrap_order +#: model:ir.model.fields.selection,name:pos_scrap_order.selection__pos_config__scrap_order_option__onhand +msgid "Create and validate Scrap Order for product has stock only" +msgstr "Créer et valider la mise au rebut seulement pour les produit en stock" + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "Data is incorrect." +msgstr "Les données sont incorrectes." + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "Error!" +msgstr "Erreur!" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.js:0 +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.js:0 +msgid "" +"It seems that you do not have a network connection at the moment. Try again " +"later." +msgstr "" +"Il semble que vous n'ayez pas de connexion réseau pour le moment. Veuillez réessayer plus tard." + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/overrides/components/scrap_buttons/scrap_buttons.xml:0 +msgid "List" +msgstr "Liste" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/overrides/components/scrap_buttons/scrap_buttons.xml:0 +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml:0 +msgid "Make Scrap Order" +msgstr "Créer un ordre de rebut" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.js:0 +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.js:0 +msgid "Network Connection Lost" +msgstr "Connexion réseau perdue" + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "No Enough Stock!" +msgstr "Stock insuffisant!" + +#. module: pos_scrap_order +#: model:ir.model.fields.selection,name:pos_scrap_order.selection__pos_config__scrap_order_option__no +msgid "No Scrap Order" +msgstr "Aucun ordre de rebut" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.js:0 +msgid "Not available" +msgstr "Non disponible" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml:0 +msgid "Origin" +msgstr "Origine" + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +#: code:addons/pos_scrap_order/models/stock_scrap.py:0 +msgid "POS Session: " +msgstr "Session POS:" + +#. module: pos_scrap_order +#: model:ir.model,name:pos_scrap_order.model_pos_config +msgid "Point of Sale Configuration" +msgstr "Configuration du point de vente" + +#. module: pos_scrap_order +#: model:ir.model,name:pos_scrap_order.model_pos_order +msgid "Point of Sale Orders" +msgstr "Commandes du point de vente" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml:0 +msgid "Product" +msgstr "Produit" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml:0 +msgid "Quantity" +msgstr "Quantité" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml:0 +msgid "Reference" +msgstr "Référence" + +#. module: pos_scrap_order +#: model:ir.model,name:pos_scrap_order.model_stock_scrap +msgid "Scrap" +msgstr "Rebut" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.xml:0 +msgid "Scrap Order" +msgstr "Ordre de rebut" + +#. module: pos_scrap_order +#: model:ir.model.fields,field_description:pos_scrap_order.field_pos_config__scrap_order_option +#: model_terms:ir.ui.view,arch_db:pos_scrap_order.pos_config_view_form_inherit +msgid "Scrap Order Option" +msgstr "Option d'ordre de rebut" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml:0 +msgid "Scrap Orders" +msgstr "Ordres de rebut" + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "Scrap order is disabled." +msgstr "L'ordre de rebut est désactivé." + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "Stock data is incorrect. Please contact the administrator." +msgstr "Les données de stock sont incorrectes. Veuillez contacter l'administrateur." + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "Successful!" +msgstr "Réussi!" + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "The product {} has no enough stock." +msgstr "Le produit {} n'a pas assez de stock." + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "The product(s) has been sent to scrap location" +msgstr "Le(s) produit(s) a(ont) été envoyé(s) vers l'emplacement de rebut" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.xml:0 +msgid "This action will move these products below into the scrap location" +msgstr "Cette action va déplacer ces produits ci-dessous vers l'emplacement de rebut" + +#. module: pos_scrap_order +#. odoo-javascript +#: code:addons/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.js:0 +msgid "This order has been paid or has no line." +msgstr "Cette commande a été payée ou n'a pas de ligne." + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "User Error!" +msgstr "Erreur d'utilisateur!" + +#. module: pos_scrap_order +#. odoo-python +#: code:addons/pos_scrap_order/models/pos_order.py:0 +msgid "You have no right to make the scrap order." +msgstr "Vous n'avez pas le droit de faire un ordre de rebut." diff --git a/pos_scrap_order/models/__init__.py b/pos_scrap_order/models/__init__.py new file mode 100644 index 000000000..e5df60392 --- /dev/null +++ b/pos_scrap_order/models/__init__.py @@ -0,0 +1,3 @@ +from . import pos_order +from . import pos_config +from . import stock_scrap diff --git a/pos_scrap_order/models/pos_config.py b/pos_scrap_order/models/pos_config.py new file mode 100644 index 000000000..1641427e8 --- /dev/null +++ b/pos_scrap_order/models/pos_config.py @@ -0,0 +1,18 @@ +# Copyright (C) Nguyen Minh Chien (chien@trobz.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html + +from odoo import fields, models + + +class PosConfig(models.Model): + _inherit = "pos.config" + + scrap_order_option = fields.Selection( + [ + ("no", "No Scrap Order"), + ("onhand", "Create and validate Scrap Order for product has stock only"), + ("always", "Always create Scrap Order, validate if has stock"), + ("force", "Always create Scrap Order, validate regardless of no stock"), + ], + default="onhand", + ) diff --git a/pos_scrap_order/models/pos_order.py b/pos_scrap_order/models/pos_order.py new file mode 100644 index 000000000..1b3c51d28 --- /dev/null +++ b/pos_scrap_order/models/pos_order.py @@ -0,0 +1,105 @@ +# Copyright (C) Nguyen Minh Chien (chien@trobz.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, models +from odoo.exceptions import AccessError, UserError + +_logger = logging.getLogger(__name__) + + +class OutofStockError(AccessError): + """Out of stock exception""" + + def __init__(self, msg): + super().__init__(msg) + + +class PosOrder(models.Model): + _inherit = "pos.order" + + @api.model + def _get_scrap_vals(self, order, line, default_vals): + vals = {} + session = self.env["pos.session"].browse(order.get("pos_session_id")) + location = session.config_id.picking_type_id.default_location_src_id + if len(line) == 3: + product = self.env["product.product"].browse(line[2].get("product_id")) + vals = { + "product_id": product.id, + "scrap_qty": line[2].get("qty"), + "product_uom_id": product.uom_id.id, + "origin": self.env._("POS Session: ") + session.display_name, + } + if location: + vals["location_id"] = location.id + vals.update(default_vals) + return vals + + @api.model + def create_scrap_from_ui(self, order, default_vals=None): + scrap_ids = [] + msg = {} + if default_vals is None: + default_vals = {} + session = self.env["pos.session"].browse(order.get("pos_session_id")) + scrap_order_option = session.config_id.scrap_order_option + lines = order.get("lines") + Scrap = self.env["stock.scrap"] + try: + with self.env.cr.savepoint(): + if scrap_order_option == "no": + raise AccessError(self.env._("Scrap order is disabled.")) + vals = [] + for line in lines: + if len(line) == 3: + vals.append(self._get_scrap_vals(order, line, default_vals)) + if len(vals) == len(lines): + scraps = Scrap.create(vals) + if scrap_order_option == "force": + scraps.do_scrap() + else: + for scrap in scraps: + res = scrap.action_validate() + if scrap_order_option == "onhand" and res is not True: + raise OutofStockError( + self.env._( + "The product {} has no enough stock." + ).format(scrap.product_id.display_name) + ) + scrap_ids = scraps.ids + msg = { + "title": self.env._("Successful!"), + "body": self.env._( + "The product(s) has been sent to scrap location" + ), + } + except OutofStockError as e: + scrap_ids = [] + msg = {"title": self.env._("No Enough Stock!"), "body": e.args[0]} + except AccessError: + scrap_ids = [] + msg = { + "title": self.env._("Access Error!"), + "body": self.env._("You have no right to make the scrap order."), + } + except UserError as err: + scrap_ids = [] + _logger.error("====================================") + _logger.error(str(err)) + msg = { + "title": self.env._("User Error!"), + "body": self.env._( + "Stock data is incorrect. Please contact the administrator." + ), + } + except Exception as err: + scrap_ids = [] + _logger.error("====================================") + _logger.error(str(err)) + msg = { + "title": self.env._("Error!"), + "body": self.env._("Data is incorrect."), + } + return {"scrap_ids": scrap_ids, "msg": msg} diff --git a/pos_scrap_order/models/stock_scrap.py b/pos_scrap_order/models/stock_scrap.py new file mode 100644 index 000000000..a2844e28d --- /dev/null +++ b/pos_scrap_order/models/stock_scrap.py @@ -0,0 +1,44 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html + +from odoo import api, fields, models + + +class StockScrap(models.Model): + _inherit = "stock.scrap" + + @api.model + def get_list_for_ui(self): + result = [] + records = self.sudo().search( + [ + "|", + ("origin", "like", self.env._("POS Session: ")), + ("origin", "like", "POS Session: "), + ], + order="id DESC", + limit=50, + ) + for scrap in records: + # Format quantity with unit of measure + quantity_str = f"{scrap.scrap_qty} {scrap.product_uom_id.name}" + + # Get state label from selection field + state_label = dict(self._fields["state"].selection).get( + scrap.state, scrap.state + ) + + result.append( + { + "name": scrap.name, + "product_display_name": scrap.product_id.display_name, + "qty_str": quantity_str, + "state_label": state_label, + "origin": scrap.origin.replace("POS Session: ", "").replace( + self.env._("POS Session: "), "" + ), + "date": scrap.create_date + and fields.Datetime.to_string(scrap.create_date) + or "", + } + ) + return result diff --git a/pos_scrap_order/pyproject.toml b/pos_scrap_order/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/pos_scrap_order/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/pos_scrap_order/readme/CONFIGURE.md b/pos_scrap_order/readme/CONFIGURE.md new file mode 100644 index 000000000..dcdbdad08 --- /dev/null +++ b/pos_scrap_order/readme/CONFIGURE.md @@ -0,0 +1,10 @@ +Go to **Point of Sale → Configuration → Settings** and locate the **Scrap Order** section. + +Set the **Scrap Order Option** field to one of the following values: + +| Option | Behaviour | +|--------|-----------| +| **No Scrap Order** | Scrap buttons are hidden in POS. No scrap order can be created. | +| **Create and validate for products with stock only** *(default)* | Creates scrap orders and validates them only if the product has sufficient on-hand quantity. | +| **Always create, validate if has stock** | Always creates scrap orders; validates them only when stock is available. | +| **Always create, validate regardless of stock** | Always creates and validates scrap orders, even if on-hand quantity is zero or negative. | diff --git a/pos_scrap_order/readme/DESCRIPTION.md b/pos_scrap_order/readme/DESCRIPTION.md new file mode 100644 index 000000000..0046cd18f --- /dev/null +++ b/pos_scrap_order/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module allows cashiers to create scrap orders directly from the Point of Sale screen, +without leaving the POS session. + +Key features: + +- **Scrap from POS**: create scrap orders for all products in the current order with one click. +- **Scrap list**: view the 50 most recent scrap orders created from POS sessions. +- **Configurable validation mode**: choose how scrap orders are validated depending on available stock. diff --git a/pos_scrap_order/readme/USAGE.md b/pos_scrap_order/readme/USAGE.md new file mode 100644 index 000000000..e708b6ed0 --- /dev/null +++ b/pos_scrap_order/readme/USAGE.md @@ -0,0 +1,20 @@ +**Creating a scrap order from POS** + +1. Add at least one product to the current order. +2. Click the **Actions** button (bottom-right area of the POS screen). +3. In the popup, click **Make Scrap Order**. +4. Review the list of products and quantities to be scrapped. +5. Click **Create Scrap Order** to confirm. +6. A confirmation dialog will appear. On success, the screen returns to the product screen. + +**Viewing recent scrap orders** + +1. Click the **Actions** button. +2. Click **Scrap List** to display the 50 most recent scrap orders originating from POS sessions. +3. Click **Make Scrap Order** to navigate to the scrap creation screen. + +**Error cases** + +- If the order is empty, an error dialog will appear. +- If the option is set to *Create and validate for products with stock only* and a product has + insufficient stock, the scrap operation is rolled back and an error message is displayed. diff --git a/pos_scrap_order/security/ir.model.access.csv b/pos_scrap_order/security/ir.model.access.csv new file mode 100644 index 000000000..3ed2e407e --- /dev/null +++ b/pos_scrap_order/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +stock_scrap_pos_user,stock_scrap_pos_user,stock.model_stock_scrap,point_of_sale.group_pos_user,1,1,1,0 diff --git a/pos_scrap_order/static/description/index.html b/pos_scrap_order/static/description/index.html new file mode 100644 index 000000000..9fd5e1c97 --- /dev/null +++ b/pos_scrap_order/static/description/index.html @@ -0,0 +1,492 @@ + + + + + +Pos Scrap Order + + + +
+

Pos Scrap Order

+ + +

Beta License: AGPL-3 AwesomeFoodCoops/odoo-production

+

This module allows cashiers to create scrap orders directly from the +Point of Sale screen, without leaving the POS session.

+

Key features:

+
    +
  • Scrap from POS: create scrap orders for all products in the +current order with one click.
  • +
  • Scrap list: view the 50 most recent scrap orders created from POS +sessions.
  • +
  • Configurable validation mode: choose how scrap orders are +validated depending on available stock.
  • +
+

Table of contents

+ +
+

Configuration

+

Go to Point of Sale → Configuration → Settings and locate the +Scrap Order section.

+

Set the Scrap Order Option field to one of the following values:

+ ++++ + + + + + + + + + + + + + + + + + + + +
OptionBehaviour
No Scrap OrderScrap buttons are hidden in POS. +No scrap order can be created.
Create and validate for +products with stock only +(default)Creates scrap orders and +validates them only if the +product has sufficient on-hand +quantity.
Always create, validate if has +stockAlways creates scrap orders; +validates them only when stock +is available.
Always create, validate +regardless of stockAlways creates and validates +scrap orders, even if on-hand +quantity is zero or negative.
+
+
+

Usage

+

Creating a scrap order from POS

+
    +
  1. Add at least one product to the current order.
  2. +
  3. Click the Actions button (bottom-right area of the POS screen).
  4. +
  5. In the popup, click Make Scrap Order.
  6. +
  7. Review the list of products and quantities to be scrapped.
  8. +
  9. Click Create Scrap Order to confirm.
  10. +
  11. A confirmation dialog will appear. On success, the screen returns to +the product screen.
  12. +
+

Viewing recent scrap orders

+
    +
  1. Click the Actions button.
  2. +
  3. Click Scrap List to display the 50 most recent scrap orders +originating from POS sessions.
  4. +
  5. Click Make Scrap Order to navigate to the scrap creation screen.
  6. +
+

Error cases

+
    +
  • If the order is empty, an error dialog will appear.
  • +
  • If the option is set to Create and validate for products with stock +only and a product has insufficient stock, the scrap operation is +rolled back and an error message is displayed.
  • +
+
+
+

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

+
    +
  • Trobz
  • +
  • La Louve
  • +
+
+
+

Maintainers

+

This module is part of the AwesomeFoodCoops/odoo-production project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/pos_scrap_order/static/src/app/overrides/components/scrap_buttons/scrap_buttons.esm.js b/pos_scrap_order/static/src/app/overrides/components/scrap_buttons/scrap_buttons.esm.js new file mode 100644 index 000000000..eb8dac0bd --- /dev/null +++ b/pos_scrap_order/static/src/app/overrides/components/scrap_buttons/scrap_buttons.esm.js @@ -0,0 +1,15 @@ +import {ProductScreen} from "@point_of_sale/app/screens/product_screen/product_screen"; +import {patch} from "@web/core/utils/patch"; + +patch(ProductScreen.prototype, { + get showScrapButtons() { + const opt = this.pos.config.scrap_order_option; + return opt !== undefined && opt !== "no"; + }, + clickScrap() { + this.pos.showScreen("ScrapScreen"); + }, + clickScrapList() { + this.pos.showScreen("ScrapListScreen"); + }, +}); diff --git a/pos_scrap_order/static/src/app/overrides/components/scrap_buttons/scrap_buttons.xml b/pos_scrap_order/static/src/app/overrides/components/scrap_buttons/scrap_buttons.xml new file mode 100644 index 000000000..44d843364 --- /dev/null +++ b/pos_scrap_order/static/src/app/overrides/components/scrap_buttons/scrap_buttons.xml @@ -0,0 +1,23 @@ + + + + + +
+ + +
+
+
+
+
diff --git a/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.esm.js b/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.esm.js new file mode 100644 index 000000000..ee9039261 --- /dev/null +++ b/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.esm.js @@ -0,0 +1,44 @@ +import {Component, onMounted, useState} from "@odoo/owl"; +import {AlertDialog} from "@web/core/confirmation_dialog/confirmation_dialog"; +import {_t} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; +import {usePos} from "@point_of_sale/app/store/pos_hook"; +import {useService} from "@web/core/utils/hooks"; + +export class ScrapListScreen extends Component { + static template = "pos_scrap_order.ScrapListScreen"; + static props = {}; + + setup() { + this.pos = usePos(); + this.dialog = useService("dialog"); + this.state = useState({scrapOrders: []}); + + onMounted(async () => { + try { + this.state.scrapOrders = await this.pos.data.call( + "stock.scrap", + "get_list_for_ui", + [] + ); + } catch { + this.dialog.add(AlertDialog, { + title: _t("Network Connection Lost"), + body: _t( + "It seems that you do not have a network connection at the moment. Try again later." + ), + }); + } + }); + } + + clickBack() { + this.pos.showScreen("ProductScreen"); + } + + clickNext() { + this.pos.showScreen("ScrapScreen"); + } +} + +registry.category("pos_screens").add("ScrapListScreen", ScrapListScreen); diff --git a/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml b/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml new file mode 100644 index 000000000..630cf288e --- /dev/null +++ b/pos_scrap_order/static/src/app/screens/scrap_list_screen/scrap_list_screen.xml @@ -0,0 +1,58 @@ + + + +
+
+ + + Back + +

Scrap Orders

+ + Make Scrap Order + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
ReferenceProductQuantityCreate DateOrigin
+ + + + + + + + + +
+
+
+
+
diff --git a/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.esm.js b/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.esm.js new file mode 100644 index 000000000..ce24fbfce --- /dev/null +++ b/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.esm.js @@ -0,0 +1,79 @@ +import {AlertDialog} from "@web/core/confirmation_dialog/confirmation_dialog"; +import {Component} from "@odoo/owl"; +import {_t} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; +import {usePos} from "@point_of_sale/app/store/pos_hook"; +import {useService} from "@web/core/utils/hooks"; + +export class ScrapScreen extends Component { + static template = "pos_scrap_order.ScrapScreen"; + static props = {}; + + setup() { + this.pos = usePos(); + this.dialog = useService("dialog"); + } + + get order() { + return this.pos.get_order(); + } + + get orderlines() { + return this.order?.lines || []; + } + + clickBack() { + this.pos.showScreen("ProductScreen"); + } + + async makeScrap() { + const order = this.order; + if (!order || order.lines.length === 0) { + this.dialog.add(AlertDialog, { + title: _t("Not available"), + body: _t("This order has been paid or has no line."), + }); + return; + } + + const orderData = { + pos_session_id: this.pos.session.id, + lines: order.lines.map((line) => [ + 0, + 0, + { + product_id: line.product_id.id, + qty: line.qty, + }, + ]), + }; + + try { + const result = await this.pos.data.call( + "pos.order", + "create_scrap_from_ui", + [orderData] + ); + const {scrap_ids, msg} = result; + if (msg && msg.title) { + this.dialog.add(AlertDialog, { + title: msg.title, + body: msg.body, + }); + } + if (scrap_ids && scrap_ids.length > 0) { + this.pos.removeOrder(order, false); + this.pos.showScreen("ProductScreen"); + } + } catch { + this.dialog.add(AlertDialog, { + title: _t("Network Connection Lost"), + body: _t( + "It seems that you do not have a network connection at the moment. Try again later." + ), + }); + } + } +} + +registry.category("pos_screens").add("ScrapScreen", ScrapScreen); diff --git a/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.xml b/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.xml new file mode 100644 index 000000000..48bd25995 --- /dev/null +++ b/pos_scrap_order/static/src/app/screens/scrap_screen/scrap_screen.xml @@ -0,0 +1,65 @@ + + + +
+
+ + + Back + +

Scrap Order

+ + Create Scrap Order + + +
+
+
+
+
+
ATTENTION
+ This action will move these products below into the scrap location +
+
+
+
+ +
+
+ + + + + + + + + + + + + +
+ + + + + + + + +
+
+
+
+
+
+ + diff --git a/pos_scrap_order/static/src/css/list.css b/pos_scrap_order/static/src/css/list.css new file mode 100644 index 000000000..bdc5772bb --- /dev/null +++ b/pos_scrap_order/static/src/css/list.css @@ -0,0 +1,26 @@ +/* ===== Scrap list table ===== */ + +.scraplist-body { + padding: 0; +} + +.scraplist-screen .container-list { + font-size: 16px; + width: 100%; + line-height: 40px; +} + +.scraplist-screen .container-list th, +.scraplist-screen .container-list td { + padding: 0 8px; +} + +.scraplist-screen .container-list tr { + transition: all 150ms linear; + background: rgb(230, 230, 230); +} + +.scraplist-screen .container-list thead > tr, +.scraplist-screen .container-list tr:nth-child(even) { + background: rgb(247, 247, 247); +} diff --git a/pos_scrap_order/static/src/css/scrap.css b/pos_scrap_order/static/src/css/scrap.css new file mode 100644 index 000000000..81422f4df --- /dev/null +++ b/pos_scrap_order/static/src/css/scrap.css @@ -0,0 +1,140 @@ +/* ===== Top bar ===== */ + +.scrap-top-bar { + height: 64px; + flex-shrink: 0; + border-bottom: dashed 1px rgb(215, 215, 215); + text-align: center; +} + +.scrap-btn { + line-height: 32px; + padding: 3px 13px; + font-size: 20px; + background: rgb(230, 230, 230); + border-radius: 3px; + border: solid 1px rgb(209, 209, 209); + cursor: pointer; + transition: all 150ms linear; + color: #555555; + user-select: none; +} + +.scrap-btn:hover { + background: #efefef; +} + +.scrap-btn:active { + background: black; + border-color: black; + color: white; +} + +.scrap-btn-highlight { + background: rgb(110, 200, 155); + color: white; + border: solid 1px rgb(110, 200, 155); +} + +.scrap-btn-highlight:hover { + background: rgb(120, 210, 165); +} + +/* ===== ScrapScreen panels ===== */ + +.scrap-left { + width: 34%; + flex-shrink: 0; + border-right: dashed 1px rgb(215, 215, 215); +} + +.scrap-right { + flex-grow: 1; +} + +/* ===== Scrap attention note ===== */ + +.pos-scrap-note { + font-size: 16px; + text-align: center; + margin-top: 15px; + color: red; +} + +/* ===== Scrap orderlines ===== */ + +.pos-scrap-container { + font-size: 0.75em; + text-align: center; + direction: ltr; +} + +.pos-scraps { + text-align: left; + background-color: white; + margin: 20px; + padding: 15px; + font-size: 14px; + padding-bottom: 30px; + display: inline-block; + font-family: "Inconsolata"; + border: solid 1px rgb(220, 220, 220); + border-radius: 3px; + overflow: hidden; +} + +.pos-scraps table { + width: 100%; + border: 0; + table-layout: fixed; +} + +.pos-scraps table td { + border: 0; + word-wrap: break-word; +} + +.pos-right-align { + text-align: right; +} + +.pos-center-align { + text-align: center; +} + +/* ===== Scrap buttons row in product screen ===== */ + +.scrap-control-btn { + background: #e2e2e2; + border: solid 1px #bfbfbf; + line-height: 38px; + text-align: center; + border-radius: 3px; + padding: 0 10px; + font-size: 18px; + cursor: pointer; + overflow: hidden; + transition: all linear 150ms; + color: #555555; + white-space: nowrap; +} + +.scrap-control-btn:hover { + background: #efefef; +} + +.scrap-control-btn:active { + background: black; + border-color: black; + color: white; +} + +.scrap-control-btn-main { + flex-grow: 3; +} + +.scrap-control-btn-secondary { + flex-shrink: 0; + width: 15%; + min-width: 50px; +} diff --git a/pos_scrap_order/tests/__init__.py b/pos_scrap_order/tests/__init__.py new file mode 100644 index 000000000..a1a4544ae --- /dev/null +++ b/pos_scrap_order/tests/__init__.py @@ -0,0 +1 @@ +from . import test_pos_scrap_order diff --git a/pos_scrap_order/tests/test_pos_scrap_order.py b/pos_scrap_order/tests/test_pos_scrap_order.py new file mode 100644 index 000000000..d2f798ef4 --- /dev/null +++ b/pos_scrap_order/tests/test_pos_scrap_order.py @@ -0,0 +1,212 @@ +# Copyright (C) Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import odoo.tests +from odoo import Command + +from odoo.addons.point_of_sale.tests.common import TestPointOfSaleCommon + + +@odoo.tests.tagged("post_install", "-at_install") +class TestPosScrapOrder(TestPointOfSaleCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.scrap_product = cls.env["product.product"].create( + { + "name": "Scrap Test Product", + "is_storable": True, + "available_in_pos": True, + "list_price": 10.0, + } + ) + cls.scrap_product_2 = cls.env["product.product"].create( + { + "name": "Scrap Test Product 2", + "is_storable": True, + "available_in_pos": True, + "list_price": 5.0, + } + ) + + def _open_session(self, scrap_order_option="always"): + self.pos_config.write({"scrap_order_option": scrap_order_option}) + self.pos_config.open_ui() + return self.pos_config.current_session_id + + def _order_data(self, session, lines): + """Build minimal order dict as sent by POS UI to create_scrap_from_ui.""" + return { + "pos_session_id": session.id, + "lines": [ + Command.create({"product_id": product.id, "qty": qty}) + for product, qty in lines + ], + } + + def _add_stock(self, product, qty): + location = self.company_data["default_warehouse"].lot_stock_id + self.env["stock.quant"].with_context( + inventory_mode=True, + allowed_company_ids=self.env.companies.ids, + ).create( + { + "product_id": product.id, + "inventory_quantity": qty, + "location_id": location.id, + } + ).action_apply_inventory() + + # ------------------------------------------------------------------------- + # create_scrap_from_ui + # ------------------------------------------------------------------------- + + def test_scrap_disabled_returns_error(self): + """option 'no': no scrap created, access error message returned.""" + session = self._open_session(scrap_order_option="no") + order = self._order_data(session, [(self.scrap_product, 2.0)]) + + result = self.env["pos.order"].create_scrap_from_ui(order) + + self.assertFalse(result["scrap_ids"]) + self.assertEqual(result["msg"]["title"], "Access Error!") + + def test_scrap_onhand_with_stock_creates_done_scrap(self): + """option 'onhand': scrap validated when stock is sufficient.""" + self._add_stock(self.scrap_product, 10) + session = self._open_session(scrap_order_option="onhand") + order = self._order_data(session, [(self.scrap_product, 3.0)]) + + result = self.env["pos.order"].create_scrap_from_ui(order) + + self.assertEqual(len(result["scrap_ids"]), 1) + self.assertEqual(result["msg"]["title"], "Successful!") + scrap = self.env["stock.scrap"].browse(result["scrap_ids"][0]) + self.assertEqual(scrap.product_id, self.scrap_product) + self.assertEqual(scrap.scrap_qty, 3.0) + self.assertEqual(scrap.state, "done") + + def test_scrap_onhand_no_stock_returns_error(self): + """option 'onhand': fails with out-of-stock error, no scrap committed.""" + session = self._open_session(scrap_order_option="onhand") + order = self._order_data(session, [(self.scrap_product, 99.0)]) + + result = self.env["pos.order"].create_scrap_from_ui(order) + + self.assertFalse(result["scrap_ids"]) + self.assertEqual(result["msg"]["title"], "No Enough Stock!") + + def test_scrap_force_ignores_missing_stock(self): + """option 'force': scrap done even with zero stock via do_scrap().""" + session = self._open_session(scrap_order_option="force") + order = self._order_data(session, [(self.scrap_product, 50.0)]) + + result = self.env["pos.order"].create_scrap_from_ui(order) + + self.assertEqual(len(result["scrap_ids"]), 1) + self.assertEqual(result["msg"]["title"], "Successful!") + scrap = self.env["stock.scrap"].browse(result["scrap_ids"][0]) + self.assertEqual(scrap.state, "done") + + def test_scrap_always_with_stock_validates(self): + """option 'always': scrap validated when stock is available.""" + self._add_stock(self.scrap_product, 10) + session = self._open_session(scrap_order_option="always") + order = self._order_data(session, [(self.scrap_product, 2.0)]) + + result = self.env["pos.order"].create_scrap_from_ui(order) + + self.assertEqual(len(result["scrap_ids"]), 1) + scrap = self.env["stock.scrap"].browse(result["scrap_ids"][0]) + self.assertEqual(scrap.state, "done") + + def test_scrap_always_no_stock_creates_draft(self): + """option 'always': scrap record created even with no stock (draft state).""" + session = self._open_session(scrap_order_option="always") + order = self._order_data(session, [(self.scrap_product, 99.0)]) + + result = self.env["pos.order"].create_scrap_from_ui(order) + + self.assertEqual(len(result["scrap_ids"]), 1) + scrap = self.env["stock.scrap"].browse(result["scrap_ids"][0]) + self.assertNotEqual(scrap.state, "done") + + def test_scrap_multiple_lines_creates_multiple_records(self): + """Multiple order lines each produce a separate stock.scrap record.""" + self._add_stock(self.scrap_product, 10) + self._add_stock(self.scrap_product_2, 10) + session = self._open_session(scrap_order_option="always") + order = self._order_data( + session, + [(self.scrap_product, 1.0), (self.scrap_product_2, 2.0)], + ) + + result = self.env["pos.order"].create_scrap_from_ui(order) + + self.assertEqual(len(result["scrap_ids"]), 2) + scraps = self.env["stock.scrap"].browse(result["scrap_ids"]) + self.assertIn(self.scrap_product, scraps.mapped("product_id")) + self.assertIn(self.scrap_product_2, scraps.mapped("product_id")) + + def test_scrap_origin_references_session(self): + """Created scrap's origin contains the POS session name.""" + self._add_stock(self.scrap_product, 5) + session = self._open_session(scrap_order_option="always") + order = self._order_data(session, [(self.scrap_product, 1.0)]) + + result = self.env["pos.order"].create_scrap_from_ui(order) + + scrap = self.env["stock.scrap"].browse(result["scrap_ids"][0]) + self.assertIn(session.display_name, scrap.origin) + + # ------------------------------------------------------------------------- + # get_list_for_ui + # ------------------------------------------------------------------------- + + def test_get_list_returns_pos_scraps(self): + """get_list_for_ui returns scraps originating from POS sessions.""" + self._add_stock(self.scrap_product, 10) + session = self._open_session(scrap_order_option="always") + order = self._order_data(session, [(self.scrap_product, 1.0)]) + result = self.env["pos.order"].create_scrap_from_ui(order) + scrap = self.env["stock.scrap"].browse(result["scrap_ids"][0]) + + scrap_list = self.env["stock.scrap"].get_list_for_ui() + names = [s["name"] for s in scrap_list] + + self.assertIn(scrap.name, names) + + def test_get_list_excludes_non_pos_scraps(self): + """get_list_for_ui does not return scraps with unrelated origin.""" + non_pos_scrap = self.env["stock.scrap"].create( + { + "product_id": self.scrap_product.id, + "scrap_qty": 1.0, + "product_uom_id": self.scrap_product.uom_id.id, + "origin": "Manual Inventory Adjustment", + } + ) + + scrap_list = self.env["stock.scrap"].get_list_for_ui() + names = [s["name"] for s in scrap_list] + + self.assertNotIn(non_pos_scrap.name, names) + + def test_get_list_fields(self): + """Each entry in get_list_for_ui has all required fields.""" + self._add_stock(self.scrap_product, 5) + session = self._open_session(scrap_order_option="always") + order = self._order_data(session, [(self.scrap_product, 1.0)]) + self.env["pos.order"].create_scrap_from_ui(order) + + scrap_list = self.env["stock.scrap"].get_list_for_ui() + self.assertTrue(scrap_list) + entry = scrap_list[0] + + for field in ("name", "product_display_name", "qty_str", "date", "origin"): + self.assertIn(field, entry, f"Missing field: {field}") + + def test_get_list_limited_to_50(self): + """get_list_for_ui returns at most 50 records.""" + scrap_list = self.env["stock.scrap"].get_list_for_ui() + self.assertLessEqual(len(scrap_list), 50) diff --git a/pos_scrap_order/views/pos_config.xml b/pos_scrap_order/views/pos_config.xml new file mode 100644 index 000000000..677d2a59b --- /dev/null +++ b/pos_scrap_order/views/pos_config.xml @@ -0,0 +1,18 @@ + + + + pos_config_view_form_inherit + pos.config + + + + + + + + + +