diff --git a/barcode_inventory/README.rst b/barcode_inventory/README.rst new file mode 100644 index 0000000..5c04ad9 --- /dev/null +++ b/barcode_inventory/README.rst @@ -0,0 +1,167 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================= +Barcode Inventory +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:6e091ec92aa76d0df302401d2b0aabbf5dd05d5d4351b9e3d45203bc50959e8b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fbarcode--interface-lightgray.png?logo=github + :target: https://github.com/OCA/barcode-interface/tree/18.0/barcode_inventory + :alt: OCA/barcode-interface +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/barcode-interface-18-0/barcode-interface-18-0-barcode_inventory + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/barcode-interface&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Perform inventory adjustments and stock counts directly from the Barcode +scanner app. + +The operator picks an internal location, scans the products found there +(with ``barcode_gs1`` installed, a GS1 label also brings its lot/serial +and quantity), types the counted quantity, and applies. Each adjustment +is recorded as a +`stock_inventory `__ +adjustment group — draft → in progress → done — so every barcode count +leaves a traceable, auditable record with its own move lines, using +Odoo's native on-hand adjustment mechanism (``stock.quant``). + +This is a feature module on top of ``barcode_scanner``: it registers its +own screens, scan handling and home-screen tile into the scanner core +without patching it, so you install only what you need. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +From the Barcode app home screen, tap **Inventory Adjustment**. + +Count a location +---------------- + +1. **Pick or scan the location** to count. Scanning a location barcode + jumps straight into the count; otherwise choose one from the + searchable list of internal locations. +2. The count screen shows the stock the location holds, one line per + product and lot. **Count by scanning** each product: a plain barcode + adds one unit. With ``barcode_gs1`` installed, a GS1 label also + brings its **lot/serial** and its **quantity**; without it, the + scanner reads plain EAN-13 barcodes only. You can also type the + counted quantity in each line, decimals included, which then shows + how far it is from the stock. +3. To count something not shown, tap **"+"** and scan or search the + product. A freshly scanned or added product jumps to the top of the + list and is highlighted, so you always see what the scan just + changed. Only storable products can be counted: services and + non-storable consumables are not offered, and scanning one says why + it is refused. +4. Tap **Apply Adjustment** to record the count. If the stock of a + counted product changed while you counted — goods came in or went out + — nothing is applied yet: the lines show the stock as it is now, and + you choose whether to apply your count anyway or to count again. + +Lots and serial numbers +----------------------- + +A lot/serial-tracked product needs its lot/serial before it can be +applied — read from a GS1 label, or typed into the line (the lots a +count at that location can use are suggested; a new name is created on +the server). The name is matched exactly first, as lots differing only +in case are different lots; in another case it is only taken when it +names a single lot. The same product can appear on **several lines**, +one per lot or serial number, so it stays available in the **"+"** +picker even after it already has a line. + +A serial number counts one unit: reading it again warns instead of +counting it twice. + +What it records +--------------- + +Each adjustment is applied as a +`stock_inventory `__ +adjustment group (draft → in progress → done), scoped to exactly the +counted products at the counted location, using Odoo's native on-hand +adjustment. Every barcode count therefore leaves one traceable, +auditable record with its own move lines. Lots are created in the +counted location's company. + +A count covers the stock at the location itself, outside packages and +not owned by a partner: its sublocations are counted on their own, and +stock in a package or owned by someone else is neither shown nor +changed. + +As in the back office, applying a count requires **Inventory +Administrator** rights. Other users are told so when they open +**Inventory Adjustment**. + +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 +------- + +* Binhex + +Contributors +------------ + +- `Zuzanna Elzbieta Szalaty + Szalaty `__ + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-szalatyzuzanna| image:: https://github.com/szalatyzuzanna.png?size=40px + :target: https://github.com/szalatyzuzanna + :alt: szalatyzuzanna + +Current `maintainer `__: + +|maintainer-szalatyzuzanna| + +This module is part of the `OCA/barcode-interface `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/barcode_inventory/__init__.py b/barcode_inventory/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/barcode_inventory/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/barcode_inventory/__manifest__.py b/barcode_inventory/__manifest__.py new file mode 100644 index 0000000..54fc891 --- /dev/null +++ b/barcode_inventory/__manifest__.py @@ -0,0 +1,31 @@ +{ + "name": "Barcode Inventory", + "version": "18.0.1.0.0", + "category": "Inventory/Inventory", + "summary": "Inventory adjustments and stock counts by barcode, on top of " + "stock_inventory", + "author": "Binhex, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/barcode-interface", + "maintainers": ["szalatyzuzanna"], + "license": "AGPL-3", + "depends": [ + "barcode_scanner", + "stock_inventory", + ], + "assets": { + "web.assets_backend": [ + "barcode_inventory/static/src/scss/inventory.scss", + "barcode_inventory/static/src/xml/inventory_templates.xml", + "barcode_inventory/static/src/js/screens/inventory_location_screen.esm.js", + "barcode_inventory/static/src/js/screens/inventory_product_selector_screen.esm.js", + "barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js", + "barcode_inventory/static/src/js/tiles/inventory_menu_tiles.esm.js", + ], + "web.assets_unit_tests": [ + "barcode_inventory/static/tests/**/*", + ], + }, + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/barcode_inventory/i18n/barcode_inventory.pot b/barcode_inventory/i18n/barcode_inventory.pot new file mode 100644 index 0000000..58030bb --- /dev/null +++ b/barcode_inventory/i18n/barcode_inventory.pot @@ -0,0 +1,301 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * barcode_inventory +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-19 08:04+0000\n" +"PO-Revision-Date: 2026-10-19 08:04+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: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "" +"%(name)s is already counted: set its serial number before counting another " +"one." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "%(name)s is not a storable product: it has no stock to count." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Add Product" +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Apply Adjustment (" +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Apply anyway" +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Applying..." +msgstr "" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "Barcode count: %(location)s" +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "COUNT — SCAN OR TYPE THE COUNTED QTY" +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Count at least one product before applying." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/tiles/inventory_menu_tiles.esm.js:0 +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Inventory Adjustment" +msgstr "" + +#. module: barcode_inventory +#: model:ir.model,name:barcode_inventory.model_stock_inventory +msgid "Inventory Adjustment Group" +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Inventory adjusted: %(n)s product(s)." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Loading locations..." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Loading products..." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Loading stock..." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Lot / Serial number" +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_location_screen.esm.js:0 +msgid "No internal location matches “%(code)s”." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "No internal locations found." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +#: code:addons/barcode_inventory/static/src/js/screens/inventory_product_selector_screen.esm.js:0 +msgid "No product matches “%(code)s”." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "No products found." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "No stock here yet — scan or add a product." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "On hand:" +msgstr "" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "One of the scanned products no longer exists." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Only inventory administrators can apply a count." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "PRODUCTS" +msgstr "" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "" +"Product %(name)s is tracked: scan or type its lot/serial number before " +"counting it." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "SELECT A LOCATION TO COUNT" +msgstr "" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "Scan at least one product to adjust before applying." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Scan or search a location..." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Scan or search a product..." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Scan, or add a product..." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Serial number %(lot)s is already counted." +msgstr "" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "Serial number %(lot)s of %(name)s can only be counted once." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Set the lot/serial for %(name)s before applying." +msgstr "" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "" +"Several lots/serial numbers of %(name)s match %(lot)s: type it exactly as it" +" is written." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Stock changed" +msgstr "" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "" +"The count could not be applied: Odoo asks for a check of the adjustment. " +"Count the location again." +msgstr "" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "The counted quantity for %(name)s cannot be negative." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "The counted quantity for %(name)s is not a number." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "The quantity on the label of %(name)s cannot be read." +msgstr "" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "The selected location is no longer valid." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "The stock changed here while you counted:" +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Wait until the count is applied to scan again." +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Your counted quantities will be set all the same. Apply the count?" +msgstr "" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "• %(what)s: %(shown)s %(uom)s when counting, %(now)s now" +msgstr "" diff --git a/barcode_inventory/i18n/es.po b/barcode_inventory/i18n/es.po new file mode 100644 index 0000000..ac4ae96 --- /dev/null +++ b/barcode_inventory/i18n/es.po @@ -0,0 +1,310 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * barcode_inventory +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-19 08:04+0000\n" +"PO-Revision-Date: 2026-10-19 08:04+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "" +"%(name)s is already counted: set its serial number before counting another " +"one." +msgstr "" +"%(name)s ya está contado: indique su número de serie antes de contar otro." + +#. module: barcode_inventory +#. odoo-javascript +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "%(name)s is not a storable product: it has no stock to count." +msgstr "" +"%(name)s no es un producto almacenable: no tiene existencias que contar." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Add Product" +msgstr "Añadir producto" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Apply Adjustment (" +msgstr "Aplicar ajuste (" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Apply anyway" +msgstr "Aplicar igualmente" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Applying..." +msgstr "Aplicando..." + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "Barcode count: %(location)s" +msgstr "Conteo por código de barras: %(location)s" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "COUNT — SCAN OR TYPE THE COUNTED QTY" +msgstr "CONTEO — ESCANEE O ESCRIBA LA CANTIDAD CONTADA" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Count at least one product before applying." +msgstr "Cuente al menos un producto antes de aplicar." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/tiles/inventory_menu_tiles.esm.js:0 +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Inventory Adjustment" +msgstr "Ajuste de inventario" + +#. module: barcode_inventory +#: model:ir.model,name:barcode_inventory.model_stock_inventory +msgid "Inventory Adjustment Group" +msgstr "Grupo de ajuste de inventario" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Inventory adjusted: %(n)s product(s)." +msgstr "Inventario ajustado: %(n)s producto(s)." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Loading locations..." +msgstr "Cargando ubicaciones..." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Loading products..." +msgstr "Cargando productos..." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Loading stock..." +msgstr "Cargando existencias..." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Lot / Serial number" +msgstr "Número de lote / serie" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_location_screen.esm.js:0 +msgid "No internal location matches “%(code)s”." +msgstr "Ninguna ubicación interna coincide con «%(code)s»." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "No internal locations found." +msgstr "No se han encontrado ubicaciones internas." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +#: code:addons/barcode_inventory/static/src/js/screens/inventory_product_selector_screen.esm.js:0 +msgid "No product matches “%(code)s”." +msgstr "Ningún producto coincide con «%(code)s»." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "No products found." +msgstr "No se han encontrado productos." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "No stock here yet — scan or add a product." +msgstr "Aún no hay existencias aquí — escanee o añada un producto." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "On hand:" +msgstr "Disponible:" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "One of the scanned products no longer exists." +msgstr "Uno de los productos escaneados ya no existe." + +#. module: barcode_inventory +#. odoo-javascript +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Only inventory administrators can apply a count." +msgstr "Solo los administradores de inventario pueden aplicar un conteo." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "PRODUCTS" +msgstr "PRODUCTOS" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "" +"Product %(name)s is tracked: scan or type its lot/serial number before " +"counting it." +msgstr "" +"El producto %(name)s se controla por lote/serie: escanee o escriba su número" +" de lote/serie antes de contarlo." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "SELECT A LOCATION TO COUNT" +msgstr "SELECCIONE UNA UBICACIÓN PARA CONTAR" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "Scan at least one product to adjust before applying." +msgstr "Escanee al menos un producto a ajustar antes de aplicar." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Scan or search a location..." +msgstr "Escanee o busque una ubicación..." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Scan or search a product..." +msgstr "Escanee o busque un producto..." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/xml/inventory_templates.xml:0 +msgid "Scan, or add a product..." +msgstr "Escanee, o añada un producto..." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Serial number %(lot)s is already counted." +msgstr "El número de serie %(lot)s ya está contado." + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "Serial number %(lot)s of %(name)s can only be counted once." +msgstr "El número de serie %(lot)s de %(name)s solo se puede contar una vez." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Set the lot/serial for %(name)s before applying." +msgstr "Indique el lote/serie de %(name)s antes de aplicar." + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "" +"Several lots/serial numbers of %(name)s match %(lot)s: type it exactly as it" +" is written." +msgstr "" +"Varios lotes/números de serie de %(name)s coinciden con %(lot)s: escríbalo " +"exactamente como está escrito." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Stock changed" +msgstr "El stock ha cambiado" + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "" +"The count could not be applied: Odoo asks for a check of the adjustment. " +"Count the location again." +msgstr "" +"No se ha podido aplicar el conteo: Odoo pide revisar el ajuste. Vuelva a " +"contar la ubicación." + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "The counted quantity for %(name)s cannot be negative." +msgstr "La cantidad contada de %(name)s no puede ser negativa." + +#. module: barcode_inventory +#. odoo-javascript +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "The counted quantity for %(name)s is not a number." +msgstr "La cantidad contada de %(name)s no es un número." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "The quantity on the label of %(name)s cannot be read." +msgstr "No se puede leer la cantidad de la etiqueta de %(name)s." + +#. module: barcode_inventory +#. odoo-python +#: code:addons/barcode_inventory/models/stock_inventory.py:0 +msgid "The selected location is no longer valid." +msgstr "La ubicación seleccionada ya no es válida." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "The stock changed here while you counted:" +msgstr "El stock de esta ubicación ha cambiado mientras contaba:" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Wait until the count is applied to scan again." +msgstr "Espere a que se aplique el conteo para volver a escanear." + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "Your counted quantities will be set all the same. Apply the count?" +msgstr "Se fijarán igualmente las cantidades contadas. ¿Aplicar el conteo?" + +#. module: barcode_inventory +#. odoo-javascript +#: code:addons/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js:0 +msgid "• %(what)s: %(shown)s %(uom)s when counting, %(now)s now" +msgstr "• %(what)s: %(shown)s %(uom)s al contar, %(now)s ahora" diff --git a/barcode_inventory/models/__init__.py b/barcode_inventory/models/__init__.py new file mode 100644 index 0000000..3553681 --- /dev/null +++ b/barcode_inventory/models/__init__.py @@ -0,0 +1 @@ +from . import stock_inventory diff --git a/barcode_inventory/models/stock_inventory.py b/barcode_inventory/models/stock_inventory.py new file mode 100644 index 0000000..782f7cc --- /dev/null +++ b/barcode_inventory/models/stock_inventory.py @@ -0,0 +1,393 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import math + +from odoo import Command, api, models +from odoo.exceptions import AccessError, UserError +from odoo.tools.float_utils import float_compare, float_is_zero, float_round + + +def _exact_ilike(value): + """A pattern for ``=ilike`` that matches ``value`` exactly, in any case: + ``%`` and ``_`` are wildcards there, so they are escaped.""" + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +class StockInventory(models.Model): + _inherit = "stock.inventory" + + @api.model + def _barcode_count_location(self, location_id): + location = self.env["stock.location"].browse(location_id).exists() + if not location or location.usage != "internal": + raise UserError(self.env._("The selected location is no longer valid.")) + return location + + @api.model + def _barcode_count_domain(self, location): + """The quants a count from the scanner covers: the stock at the location + itself, outside packages and not owned by a partner. + + The scanner shows one line per product and lot, so it cannot tell apart + what sits in a package or belongs to someone else; those quants are + neither shown nor changed. Sublocations are counted on their own. + """ + return [ + ("location_id", "=", location.id), + ("package_id", "=", False), + ("owner_id", "=", False), + ] + + @api.model + def action_barcode_location_lines(self, location_id): + """Return the stock a location holds as count lines, one per product + and lot, so the scanner can show it before the operator counts. + + This is a plain read: no inventory adjustment is created yet. It is only + built when the operator applies the count (``action_barcode_apply_count``), + so backing out of the screen leaves no adjustment in progress. + """ + location = self._barcode_count_location(location_id) + groups = self.env["stock.quant"]._read_group( + self._barcode_count_domain(location), + ["product_id", "lot_id"], + ["quantity:sum"], + ) + lines = [] + for product, lot, quantity in groups: + rounding = product.uom_id.rounding + if float_is_zero(quantity, precision_rounding=rounding): + continue + lines.append( + { + "product_id": product.id, + "product_name": product.display_name, + "tracking": product.tracking, + "lot_id": lot.id, + "lot_name": lot.name or False, + "theoretical_qty": float_round( + quantity, precision_rounding=rounding + ), + "uom": product.uom_id.name, + } + ) + return { + "location_id": location.id, + "location_name": location.display_name, + "lines": lines, + } + + def _get_quants(self, locations): + # A count from the scanner hands over exactly the quants it counts + # (see action_barcode_apply_count): the adjustment must not take in, + # nor mark as to do, the ones in a package or owned by a partner. + quant_ids = self.env.context.get("barcode_count_quant_ids") + if quant_ids is not None: + return self.env["stock.quant"].browse(quant_ids) + return super()._get_quants(locations) + + @api.model + def _barcode_lot_domain(self, product, company): + """The lots a count of ``product`` in ``company`` may name: the + product's own, in its company -- or in ``company`` for a shared + product -- or shared ones.""" + lot_company = product.company_id or company + return [ + ("product_id", "=", product.id), + ("company_id", "in", [lot_company.id, False]), + ] + + @api.model + def action_barcode_lot_names(self, location_id, product_id, search="", limit=50): + """The names the lot field of a count line suggests: the lots + ``_barcode_resolve_lot`` would find for a count at that location, so + that no suggestion turns into a new lot once applied. They are looked + up as the operator types (``search``), a few at a time: a product may + have thousands of lots. + """ + location = self._barcode_count_location(location_id) + product = self.env["product.product"].browse(product_id).exists() + if not product: + return [] + company = location.company_id or self.env.company + domain = self._barcode_lot_domain(product, company) + search = (search or "").strip() + if search: + domain += [("name", "ilike", _exact_ilike(search))] + lots = self.env["stock.lot"].search(domain, order="name", limit=limit) + return lots.mapped("name") + + @api.model + def _barcode_find_lot(self, product, lot_name, company): + """The lot/serial a name stands for, if there is one. + + The scan carries a lot *name* (from a GS1 label or typed in), never a + database id. Lots that differ only in case are distinct lots, so the + name is matched exactly first; in another case it is only taken when it + names a single lot. + """ + Lot = self.env["stock.lot"] + domain = self._barcode_lot_domain(product, company) + lot = Lot.search(domain + [("name", "=", lot_name)], limit=1) + if not lot: + lot = Lot.search( + domain + [("name", "=ilike", _exact_ilike(lot_name))], limit=2 + ) + if len(lot) > 1: + raise UserError( + self.env._( + "Several lots/serial numbers of %(name)s match %(lot)s: type " + "it exactly as it is written.", + lot=lot_name, + name=product.display_name, + ) + ) + return lot + + @api.model + def _barcode_resolve_lot(self, product, lot_name, company): + """Find the lot/serial a name stands for (see ``_barcode_find_lot``), + or create it. + + A new lot is created in ``company`` -- the company of the location being + counted -- unless the product belongs to a company. A shared product + counted in another company's location would otherwise get a lot in the + wrong company, and Odoo would refuse the adjustment. + """ + return self._barcode_find_lot(product, lot_name, company) or self.env[ + "stock.lot" + ].create( + { + "name": lot_name, + "product_id": product.id, + "company_id": (product.company_id or company).id, + } + ) + + @api.model + def _barcode_prepare_count_lines(self, lines, location): + """Validate the scanned count lines and resolve their products and lots. + + Returns the counted quantity per ``(product, lot)``: lines naming the + same lot of a product add up, as they count the same stock. Raises a + ``UserError`` the scanner shows to the operator when a line cannot be + applied (a product gone or not stocked, a negative quantity, a tracked + product without its lot, a serial number counted as more than one). + """ + company = location.company_id or self.env.company + counts = {} + for line in lines or []: + product = ( + self.env["product.product"].browse(line.get("product_id")).exists() + ) + if not product: + raise UserError( + self.env._("One of the scanned products no longer exists.") + ) + # Checked here so the operator gets this message instead of Odoo's + # quant error when the adjustment is applied. + if not product.is_storable: + raise UserError( + self.env._( + "%(name)s is not a storable product: it has no stock to " + "count.", + name=product.display_name, + ) + ) + counted = self._barcode_counted_qty(line, product) + rounding = product.uom_id.rounding + if float_compare(counted, 0, precision_rounding=rounding) < 0: + raise UserError( + self.env._( + "The counted quantity for %(name)s cannot be negative.", + name=product.display_name, + ) + ) + lot = self.env["stock.lot"] + if product.tracking != "none": + lot_name = (line.get("lot_name") or "").strip() + if not lot_name: + raise UserError( + self.env._( + "Product %(name)s is tracked: scan or type its " + "lot/serial number before counting it.", + name=product.display_name, + ) + ) + lot = self._barcode_resolve_lot(product, lot_name, company) + counts[product, lot] = counts.get((product, lot), 0) + counted + if not counts: + raise UserError( + self.env._("Scan at least one product to adjust before applying.") + ) + for (product, lot), counted in counts.items(): + if product.tracking == "serial" and counted not in (0, 1): + raise UserError( + self.env._( + "Serial number %(lot)s of %(name)s can only be counted " + "once.", + lot=lot.name, + name=product.display_name, + ) + ) + return counts + + @api.model + def _barcode_counted_qty(self, line, product): + # A line with no quantity counts nothing; an empty one (JSON null, as + # an infinite number is sent) is not a number. + try: + counted = float(line.get("counted_qty", 0)) + except (TypeError, ValueError): + counted = math.nan + if not math.isfinite(counted): + raise UserError( + self.env._( + "The counted quantity for %(name)s is not a number.", + name=product.display_name, + ) + ) + return counted + + @api.model + def _barcode_stock_changes(self, location, lines): + """The counted products and lots whose stock at the location is no + longer what the screen showed (each line's ``theoretical_qty``): stock + came in or went out since the count began. + + A count states how much there is, so applying it over such a change + would undo the change unseen. Lines that do not say what they showed + are not checked. + """ + company = location.company_id or self.env.company + seen = {} + for line in lines or []: + if line.get("theoretical_qty") is None: + continue + product = ( + self.env["product.product"].browse(line.get("product_id")).exists() + ) + if not product: + continue + lot_name = (line.get("lot_name") or "").strip() + lot = self.env["stock.lot"] + if product.tracking != "none" and lot_name: + lot = self._barcode_find_lot(product, lot_name, company) + # A lot not created yet holds nothing; its name keeps it apart. + key = (product, lot, "" if lot else lot_name) + shown = float(line["theoretical_qty"]) + seen[key] = max(seen.get(key, shown), shown) + changed = [] + for (product, lot, lot_name), shown in seen.items(): + now = 0.0 + if lot or not lot_name: + quants = self.env["stock.quant"].search( + self._barcode_count_domain(location) + + [("product_id", "=", product.id), ("lot_id", "=", lot.id)] + ) + now = sum(quants.mapped("quantity")) + rounding = product.uom_id.rounding + if float_compare(now, shown, precision_rounding=rounding): + changed.append( + { + "product_id": product.id, + "product_name": product.display_name, + "lot_name": lot.name or lot_name, + "shown": shown, + "now": float_round(now, precision_rounding=rounding), + "uom": product.uom_id.name, + } + ) + return changed + + @api.model + def action_barcode_apply_count(self, location_id, lines, force=False): + """Apply the operator's count as an inventory adjustment of one location. + + Builds a ``stock.inventory`` adjustment (OCA ``stock_inventory``) of + exactly the counted products at that location, sets the counted + quantity on each one's stock -- creating it when the location had none + -- and applies it through Odoo's own ``action_apply_inventory``. The + adjustment is then closed, so every count from the scanner leaves one + adjustment record with its own moves. + + Should the stock of a counted product have changed since the screen + showed it, nothing is applied: the changes come back for the operator to + decide, and ``force`` applies the count all the same -- the choice the + back office offers for outdated counts. + + Applying inventory quantities is restricted by Odoo to inventory + administrators (``stock.group_stock_manager``). + """ + if not self.env.user.has_group("stock.group_stock_manager"): + raise AccessError( + self.env._("Only inventory administrators can apply a count.") + ) + location = self._barcode_count_location(location_id) + if not force: + changed = self._barcode_stock_changes(location, lines) + if changed: + return {"done": False, "changed": changed} + counts = self._barcode_prepare_count_lines(lines, location) + products = self.env["product.product"].union(*(p for p, _lot in counts)) + group = self.create( + { + "name": self.env._( + "Barcode count: %(location)s", location=location.display_name + ), + "company_id": (location.company_id or self.env.company).id, + "location_ids": [Command.set(location.ids)], + "exclude_sublocation": True, + "product_selection": "manual", + "product_ids": [Command.set(products.ids)], + "responsible_id": self.env.user.id, + } + ) + # Every counted product and lot needs its stock at the location before + # the adjustment starts, so that the adjustment takes it in and the move + # applying the count is recorded on it. + Quant = self.env["stock.quant"] + quants_by_key = {} + for product, lot in counts: + quants = Quant.search( + self._barcode_count_domain(location) + + [("product_id", "=", product.id), ("lot_id", "=", lot.id)] + ) + quants_by_key[product, lot] = quants or Quant.create( + { + "product_id": product.id, + "location_id": location.id, + "lot_id": lot.id, + } + ) + counted_quants = Quant.union(*quants_by_key.values()) + group.with_context( + barcode_count_quant_ids=counted_quants.ids + ).action_state_to_in_progress() + for key, quants in quants_by_key.items(): + # Should the stock of a product and lot be split over several quants, + # the count goes on the first one and the others are emptied. + quants[:1].inventory_quantity = counts[key] + quants[1:].inventory_quantity = 0 + action = counted_quants.action_apply_inventory() + if isinstance(action, dict): + # Odoo hands back a wizard instead of applying when the quantities + # changed since they were counted, or a tracked product lacks its + # lot. Neither can happen here -- the counts were just written and + # tracked lines carry their lot -- but should one, nothing is + # recorded as done: the whole count is rolled back. + raise UserError( + self.env._( + "The count could not be applied: Odoo asks for a check of " + "the adjustment. Count the location again." + ) + ) + group.action_state_to_done() + return { + "done": True, + "group_id": group.id, + "group_name": group.name, + "adjusted": len(counts), + "move_count": len(group.stock_move_ids), + } diff --git a/barcode_inventory/pyproject.toml b/barcode_inventory/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/barcode_inventory/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/barcode_inventory/readme/CONTRIBUTORS.md b/barcode_inventory/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..c065947 --- /dev/null +++ b/barcode_inventory/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* [Zuzanna Elzbieta Szalaty Szalaty](https://github.com/szalatyzuzanna) diff --git a/barcode_inventory/readme/DESCRIPTION.md b/barcode_inventory/readme/DESCRIPTION.md new file mode 100644 index 0000000..433ae30 --- /dev/null +++ b/barcode_inventory/readme/DESCRIPTION.md @@ -0,0 +1,14 @@ +Perform inventory adjustments and stock counts directly from the Barcode +scanner app. + +The operator picks an internal location, scans the products found there +(with `barcode_gs1` installed, a GS1 label also brings its lot/serial and +quantity), types the counted quantity, and applies. Each adjustment is recorded as a +[stock_inventory](https://github.com/OCA/stock-logistics-warehouse) adjustment +group — draft → in progress → done — so every barcode count leaves a traceable, +auditable record with its own move lines, using Odoo's native on-hand +adjustment mechanism (`stock.quant`). + +This is a feature module on top of `barcode_scanner`: it registers its own +screens, scan handling and home-screen tile into the scanner core without +patching it, so you install only what you need. diff --git a/barcode_inventory/readme/USAGE.md b/barcode_inventory/readme/USAGE.md new file mode 100644 index 0000000..88e6b7c --- /dev/null +++ b/barcode_inventory/readme/USAGE.md @@ -0,0 +1,55 @@ +From the Barcode app home screen, tap **Inventory Adjustment**. + +## Count a location + +1. **Pick or scan the location** to count. Scanning a location barcode + jumps straight into the count; otherwise choose one from the searchable + list of internal locations. +2. The count screen shows the stock the location holds, one line per + product and lot. **Count by scanning** each product: a plain barcode + adds one unit. With `barcode_gs1` installed, a GS1 label also brings + its **lot/serial** and its **quantity**; without it, the scanner reads + plain EAN-13 barcodes only. You can also type the counted quantity in + each line, decimals included, which then shows how far it is from the + stock. +3. To count something not shown, tap **"+"** and scan or search the + product. A freshly scanned or added product jumps to the top of the + list and is highlighted, so you always see what the scan just changed. + Only storable products can be counted: services and non-storable + consumables are not offered, and scanning one says why it is refused. +4. Tap **Apply Adjustment** to record the count. If the stock of a + counted product changed while you counted — goods came in or went out — + nothing is applied yet: the lines show the stock as it is now, and you + choose whether to apply your count anyway or to count again. + +## Lots and serial numbers + +A lot/serial-tracked product needs its lot/serial before it can be +applied — read from a GS1 label, or typed into the line (the lots a +count at that location can use are suggested; a new name is created on +the server). The name is +matched exactly first, as lots differing only in case are different lots; +in another case it is only taken when it names a single lot. The same +product can appear on **several lines**, one per lot or serial number, so +it stays available in the **"+"** picker even after it already has a line. + +A serial number counts one unit: reading it again warns instead of +counting it twice. + +## What it records + +Each adjustment is applied as a +[stock_inventory](https://github.com/OCA/stock-logistics-warehouse) +adjustment group (draft → in progress → done), scoped to exactly the +counted products at the counted location, using Odoo's native on-hand +adjustment. Every barcode count therefore leaves one traceable, auditable +record with its own move lines. Lots are created in the counted location's +company. + +A count covers the stock at the location itself, outside packages and not +owned by a partner: its sublocations are counted on their own, and stock in +a package or owned by someone else is neither shown nor changed. + +As in the back office, applying a count requires **Inventory +Administrator** rights. Other users are told so when they open +**Inventory Adjustment**. diff --git a/barcode_inventory/static/description/icon.png b/barcode_inventory/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/barcode_inventory/static/description/icon.png differ diff --git a/barcode_inventory/static/description/index.html b/barcode_inventory/static/description/index.html new file mode 100644 index 0000000..4886a3c --- /dev/null +++ b/barcode_inventory/static/description/index.html @@ -0,0 +1,509 @@ + + + + + +Barcode Inventory + + + +
+ + + +Odoo Community Association + +
+

Barcode Inventory

+ +

Beta License: AGPL-3 OCA/barcode-interface Translate me on Weblate Try me on Runboat

+

Perform inventory adjustments and stock counts directly from the Barcode +scanner app.

+

The operator picks an internal location, scans the products found there +(with barcode_gs1 installed, a GS1 label also brings its lot/serial +and quantity), types the counted quantity, and applies. Each adjustment +is recorded as a +stock_inventory +adjustment group — draft → in progress → done — so every barcode count +leaves a traceable, auditable record with its own move lines, using +Odoo’s native on-hand adjustment mechanism (stock.quant).

+

This is a feature module on top of barcode_scanner: it registers its +own screens, scan handling and home-screen tile into the scanner core +without patching it, so you install only what you need.

+

Table of contents

+ +
+

Usage

+

From the Barcode app home screen, tap Inventory Adjustment.

+
+

Count a location

+
    +
  1. Pick or scan the location to count. Scanning a location barcode +jumps straight into the count; otherwise choose one from the +searchable list of internal locations.
  2. +
  3. The count screen shows the stock the location holds, one line per +product and lot. Count by scanning each product: a plain barcode +adds one unit. With barcode_gs1 installed, a GS1 label also +brings its lot/serial and its quantity; without it, the +scanner reads plain EAN-13 barcodes only. You can also type the +counted quantity in each line, decimals included, which then shows +how far it is from the stock.
  4. +
  5. To count something not shown, tap “+” and scan or search the +product. A freshly scanned or added product jumps to the top of the +list and is highlighted, so you always see what the scan just +changed. Only storable products can be counted: services and +non-storable consumables are not offered, and scanning one says why +it is refused.
  6. +
  7. Tap Apply Adjustment to record the count. If the stock of a +counted product changed while you counted — goods came in or went out +— nothing is applied yet: the lines show the stock as it is now, and +you choose whether to apply your count anyway or to count again.
  8. +
+
+
+

Lots and serial numbers

+

A lot/serial-tracked product needs its lot/serial before it can be +applied — read from a GS1 label, or typed into the line (the lots a +count at that location can use are suggested; a new name is created on +the server). The name is matched exactly first, as lots differing only +in case are different lots; in another case it is only taken when it +names a single lot. The same product can appear on several lines, +one per lot or serial number, so it stays available in the “+” +picker even after it already has a line.

+

A serial number counts one unit: reading it again warns instead of +counting it twice.

+
+
+

What it records

+

Each adjustment is applied as a +stock_inventory +adjustment group (draft → in progress → done), scoped to exactly the +counted products at the counted location, using Odoo’s native on-hand +adjustment. Every barcode count therefore leaves one traceable, +auditable record with its own move lines. Lots are created in the +counted location’s company.

+

A count covers the stock at the location itself, outside packages and +not owned by a partner: its sublocations are counted on their own, and +stock in a package or owned by someone else is neither shown nor +changed.

+

As in the back office, applying a count requires Inventory +Administrator rights. Other users are told so when they open +Inventory Adjustment.

+
+
+
+

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

+
    +
  • Binhex
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

szalatyzuzanna

+

This module is part of the OCA/barcode-interface project on GitHub.

+

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

+
+
+
+
+ + diff --git a/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js b/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js new file mode 100644 index 0000000..a84cea9 --- /dev/null +++ b/barcode_inventory/static/src/js/screens/inventory_count_screen.esm.js @@ -0,0 +1,596 @@ +import {Component, onWillStart, useState} from "@odoo/owl"; +import {ConfirmationDialog} from "@web/core/confirmation_dialog/confirmation_dialog"; +import {_t} from "@web/core/l10n/translation"; +import {barcodeMatchAnyDomain} from "@barcode_scanner/js/utils/scan_match.esm"; +import {barcodeScreens} from "@barcode_scanner/js/registries.esm"; +import {formatFloat} from "@web/core/utils/numbers"; +import {useBarcodeHandler} from "@barcode_scanner/js/hooks/use_barcode_handler.esm"; +import {useBarcodeScanner} from "@barcode_scanner/js/hooks/use_inventory.esm"; +import {useService} from "@web/core/utils/hooks"; + +/** + * Only storable products have stock to count. A service, a non-storable + * consumable or a combo has no quant, and Odoo refuses to create one when the + * adjustment is applied, so they are kept out of the count from the start. + */ +export const COUNTABLE_PRODUCTS = [["is_storable", "=", true]]; + +/** + * What the operator is told when the product scanned has no stock to count. + * + * @param {Object} product the product scanned + * @returns {String} the message + */ +export function notStorableMessage(product) { + return _t("%(name)s is not a storable product: it has no stock to count.", { + name: product.display_name, + }); +} + +/** + * Count step of an inventory adjustment. Shows the stock the location holds, + * one line per product and lot; the operator counts by scanning (a GS1 label + * also brings its lot/serial and piece count), by adding a product from the + * "+" product selector -- like the back office, so a product not yet in the + * location can be added -- and by typing the counted quantity. Lot/serial + * tracked lines without a lot get a lot field (suggesting the product's lots; + * the server finds or creates the lot by name). Applying builds and closes a + * stock_inventory adjustment. + * + * The count in progress survives the round-trip to the product selector: it is + * carried through the navigation params and restored on the way back. + */ +export class InventoryCountScreen extends Component { + setup() { + this.inventory = useBarcodeScanner(); + this.store = useService("barcodeStore"); + this._seq = 0; + this.state = useState({ + locationId: null, + locationName: "", + lines: [], + loading: true, + applying: false, + }); + + useBarcodeHandler({ + onScan: async (barcode, parsedData) => { + await this.onBarcodeScanned(barcode, parsedData); + }, + }); + + onWillStart(async () => { + const params = this.props.params || {}; + this.state.locationId = params.locationId || null; + this.state.locationName = params.locationName || ""; + if (Array.isArray(params.lines)) { + // Coming back from the product selector: restore the count. + this.state.lines = params.lines.map((l) => ({...l})); + this._seq = this.state.lines.reduce( + (max, l) => Math.max(max, l._id || 0), + 0 + ); + this.state.loading = false; + if (params.addProduct) { + await this.addOrIncrement(params.addProduct, null, null); + } + } else { + await this.loadLines(); + } + }); + } + + lineKey(productId, lotName) { + return `${productId}|${lotName || ""}`; + } + + async loadLines() { + if (!this.state.locationId) { + this.state.loading = false; + return; + } + const data = await this.inventory.call( + "stock.inventory", + "action_barcode_location_lines", + [this.state.locationId] + ); + this.state.lines = (data.lines || []).map((line) => ({ + _id: ++this._seq, + ...line, + // Stock already here has its lot resolved; the operator just counts. + lot_fixed: line.tracking !== "none" && Boolean(line.lot_name), + lot_options: [], + counted: null, + isNew: false, + // The stock the count is checked against when applied: what the + // screen showed as counting began (see showStockChanges). + checked_qty: line.theoretical_qty, + })); + this.state.loading = false; + } + + // --- scanning ------------------------------------------------------------- + + async onBarcodeScanned(barcode, parsed) { + if (this.waitForApply()) { + return; + } + const product = await this.findScannedProduct(barcode, parsed); + // The count may have been applied while the product was looked up. + if (!product || this.waitForApply()) { + return; + } + const lotName = parsed?.lot || parsed?.serial || null; + const qty = this.scannedQuantity(parsed, product.uom_id?.[0]); + if (qty === null) { + this.inventory.notify( + _t("The quantity on the label of %(name)s cannot be read.", { + name: product.display_name, + }), + {type: "warning"} + ); + return; + } + await this.addOrIncrement(product, lotName, qty); + } + + /** + * Whether the count is on its way to the server, telling the operator so: + * a scan now would show up as counted without being so, then vanish with + * the screen. + * + * @returns {Boolean} whether the scan must wait + */ + waitForApply() { + if (!this.state.applying) { + return false; + } + this.inventory.notify(_t("Wait until the count is applied to scan again."), { + type: "warning", + }); + return true; + } + + /** + * The product a scan names, matched the same tolerant, all-candidates way + * the other screens do: a GS1 GTIN has several equivalent variant forms, + * and the parser's value can differ from the raw scan. When it names none + * that can be counted, the operator is told why and null comes back. + * + * @param {String} barcode the raw scan + * @param {Object} [parsed] what the parsers read from it + * @returns {Promise} the product to count, if any + */ + async findScannedProduct(barcode, parsed) { + const candidates = [ + ...(parsed?.productCodes || []), + parsed?.gtin, + parsed?.value, + barcode, + ]; + const domain = barcodeMatchAnyDomain(candidates); + const products = domain + ? await this.inventory.searchRead("product.product", domain, [ + "id", + "display_name", + "tracking", + "uom_id", + "is_storable", + ]) + : []; + if (!products.length) { + this.inventory.notify( + _t("No product matches “%(code)s”.", {code: parsed?.value || barcode}), + {type: "warning"} + ); + return null; + } + // Say why a scanned service or consumable is refused, rather than + // "no match" for a product the operator can see in the catalogue. + const product = products.find((p) => p.is_storable); + if (!product) { + this.inventory.notify(notStorableMessage(products[0]), {type: "warning"}); + return null; + } + return product; + } + + /** + * How much of the product a scan stands for: the piece count the barcode + * states (a GS1 count, AI 30/37) -- zero included -- or one unit when it + * states none; null when what it states is not a quantity. + * + * `productUomId` is the unit the product is stocked in. This base reading + * ignores it, as barcode_stock's does; it is the seam a module that reads + * richer barcodes patches, to turn a GS1 net weight into the product's unit. + */ + // eslint-disable-next-line no-unused-vars + scannedQuantity(scan, productUomId) { + const stated = scan?.qty ?? scan?.quantity; + if (stated === undefined || stated === null || stated === "") { + return 1; + } + const qty = Number(stated); + return Number.isFinite(qty) && qty >= 0 ? qty : null; + } + + // --- add a product from the "+" selector ---------------------------------- + + addProduct() { + if (this.state.applying) { + return; + } + this.store.navigate("inventory_product_selector", { + locationId: this.state.locationId, + locationName: this.state.locationName, + lines: this.state.lines.map((l) => ({...l})), + // Keep out of the "+" picker only the untracked products already + // listed (those can have a single line). Lot/serial-tracked + // products stay pickable: the operator legitimately needs several + // lines for the same product, one per lot/serial number. + excludeProductIds: [ + ...new Set( + this.state.lines + .filter((l) => (l.tracking || "none") === "none") + .map((l) => l.product_id) + ), + ], + }); + } + + // --- shared add/increment ------------------------------------------------- + + /** + * Add a scanned or picked product and lot to the count, or add to its line. + * + * @param {Object} product the product + * @param {String|null} lotName the lot/serial number read, if any + * @param {Number|null} qty what a scan counts; null when the product is + * picked from the "+" selector, which counts nothing + */ + async addOrIncrement(product, lotName, qty) { + const tracking = product.tracking || "none"; + const key = this.lineKey(product.id, lotName); + const line = this.state.lines.find( + (l) => this.lineKey(l.product_id, l.lot_name) === key + ); + if (line) { + this.countAgain(line, product, lotName, qty); + return; + } + const newLine = { + _id: ++this._seq, + product_id: product.id, + product_name: product.display_name, + tracking, + lot_id: false, + lot_name: lotName || "", + theoretical_qty: 0, + checked_qty: 0, + uom: product.uom_id?.[1] || "", + lot_fixed: tracking !== "none" && Boolean(lotName), + lot_options: [], + counted: qty === null ? null : this.serialAware(tracking, qty), + isNew: true, + }; + // Put a freshly scanned/added product at the TOP, where the operator is + // looking, instead of appending it out of sight at the bottom. + this.state.lines.unshift(newLine); + this.flashLine(newLine); + if (this.needsLotInput(newLine)) { + await this.loadLotOptions(newLine); + } + } + + /** + * A product and lot already listed is scanned or picked again: add what it + * stands for to its line -- a serial number only ever counts one unit -- + * and show the operator which line that is. + * + * @param {Object} line the line of that product and lot + * @param {Object} product the product scanned or picked + * @param {String|null} lotName the lot/serial number read, if any + * @param {Number|null} qty the quantity the scan stands for; null when + * picked from the selector + */ + countAgain(line, product, lotName, qty) { + const serial = product.tracking === "serial"; + if (qty !== null && serial && this.isCounted(line)) { + // A serial number is one unit: reading it again counts nothing. + this.inventory.notify(this.serialCountedMessage(product, lotName), { + type: "warning", + }); + } else if (qty !== null) { + line.counted = serial + ? this.serialAware("serial", qty) + : String((parseFloat(line.counted) || 0) + qty); + } + // The line may be far down a long list: flag it so the operator sees + // what the scan just changed, and bring it into view. + this.flashLine(line); + } + + /** + * The count a scan gives a line: a serial number is one unit, or none. + * + * @param {String} tracking the product's tracking + * @param {Number} qty the quantity scanned + * @returns {String} the counted quantity + */ + serialAware(tracking, qty) { + if (tracking === "serial") { + return qty > 0 ? "1" : "0"; + } + return String(qty); + } + + serialCountedMessage(product, lotName) { + if (lotName) { + return _t("Serial number %(lot)s is already counted.", {lot: lotName}); + } + return _t( + "%(name)s is already counted: set its serial number before counting another one.", + {name: product.display_name} + ); + } + + /** + * Draw the operator's eye to the line a scan just added or changed: mark it + * highlighted for a moment and scroll it into view. Purely visual; it never + * changes the counted data. + * + * @param {Object} line the count line to highlight + */ + flashLine(line) { + line._flash = true; + clearTimeout(line._flashTimer); + line._flashTimer = setTimeout(() => { + line._flash = false; + }, 1500); + // Scroll after the DOM has the (re)ordered line. + requestAnimationFrame(() => { + const el = document.getElementById(`inv-count-line-${line._id}`); + if (el) { + el.scrollIntoView({behavior: "smooth", block: "nearest"}); + } + }); + } + + // --- lot handling --------------------------------------------------------- + + /** + * Suggest the lots the server would take for this line: its company's and + * the shared ones, so that no suggestion becomes a new lot once applied. + * + * @param {Object} line the count line that needs its lot + * @param {String} [search] what the operator has typed so far + */ + async loadLotOptions(line, search = "") { + // They are looked up again as the operator types: only the latest + // answer may fill the list, whatever order the answers come back in. + line._lotSearch = (line._lotSearch || 0) + 1; + const query = line._lotSearch; + const names = await this.inventory.call( + "stock.inventory", + "action_barcode_lot_names", + [this.state.locationId, line.product_id, search] + ); + if (query === line._lotSearch) { + line.lot_options = names; + } + } + + // While the count is applied, the lines stay as they were sent. + + setLotName(line, value) { + if (!this.state.applying) { + line.lot_name = value; + this.loadLotOptions(line, value); + } + } + + needsLotInput(line) { + return line.tracking !== "none" && !line.lot_fixed; + } + + // --- quantities ----------------------------------------------------------- + + setCounted(line, value) { + if (!this.state.applying) { + line.counted = value === "" ? null : value; + } + } + + isCounted(line) { + return line.counted !== null && line.counted !== ""; + } + + diff(line) { + if (!this.isCounted(line)) { + return null; + } + return (parseFloat(line.counted) || 0) - line.theoretical_qty; + } + + /** + * How a difference reads on the line: its sign, and nothing after the + * decimal point that is not there (`+2`, `-0.5`, `0`). + * + * @param {Number} value the difference between the count and the stock + * @returns {String} the difference to show + */ + formatDiff(value) { + const text = this.formatQty(value); + return value > 0 ? `+${text}` : text; + } + + formatQty(value) { + return formatFloat(value, {trailingZeros: false}); + } + + diffClass(line) { + const diff = this.diff(line); + if (diff > 0) { + return "ilx-inv-diff-up"; + } + return diff < 0 ? "ilx-inv-diff-down" : "ilx-inv-diff-none"; + } + + removeLine(line) { + if (this.state.applying) { + return; + } + const index = this.state.lines.indexOf(line); + if (index !== -1) { + this.state.lines.splice(index, 1); + } + } + + get countedLines() { + return this.state.lines.filter((l) => this.isCounted(l)); + } + + // --- apply ---------------------------------------------------------------- + + /** + * Apply the counted lines. Should the stock of a counted product have + * changed since the screen showed it, the server applies nothing and says + * so: the operator is asked whether to apply anyway, and `confirmed` is + * the count that answer was about -- it is only forced while the counted + * lines are still exactly those. + * + * @param {Boolean} force apply over the stock changes + * @param {String} [confirmed] the count the operator confirmed + */ + async apply(force = false, confirmed = null) { + if (this.state.applying) { + return; + } + const counted = this.countedLines; + if (!counted.length) { + this.inventory.notify(_t("Count at least one product before applying."), { + type: "warning", + }); + return; + } + const missingLot = counted.find( + (l) => l.tracking !== "none" && !String(l.lot_name || "").trim() + ); + if (missingLot) { + this.inventory.notify( + _t("Set the lot/serial for %(name)s before applying.", { + name: missingLot.product_name, + }), + {type: "warning"} + ); + return; + } + const notNumber = counted.find((l) => !Number.isFinite(Number(l.counted))); + if (notNumber) { + this.inventory.notify( + _t("The counted quantity for %(name)s is not a number.", { + name: notNumber.product_name, + }), + {type: "warning"} + ); + return; + } + const payload = counted.map((l) => ({ + product_id: l.product_id, + lot_name: l.lot_name || "", + counted_qty: Number(l.counted), + theoretical_qty: l.checked_qty ?? l.theoretical_qty, + })); + // What the operator counted, without the stock shown next to it: the + // stock changes the warning is about update that. + const signature = JSON.stringify( + payload.map(({product_id, lot_name, counted_qty}) => [ + product_id, + lot_name, + counted_qty, + ]) + ); + this.state.applying = true; + try { + const result = await this.inventory.call( + "stock.inventory", + "action_barcode_apply_count", + [this.state.locationId, payload], + {force: force && confirmed === signature} + ); + if (!result.done) { + this.state.applying = false; + this.showStockChanges(result.changed || [], signature); + return; + } + this.inventory.notify( + _t("Inventory adjusted: %(n)s product(s).", {n: result.adjusted}), + {type: "success"} + ); + this.store.navigate("main", {}, {clearHistory: true}); + } catch { + // The API wrapper already surfaced the server error to the operator. + this.state.applying = false; + } + } + + /** + * The stock of some counted products changed since the screen showed it: + * show the stock as it is now on their lines, and ask whether to apply the + * count all the same, as the back office does with outdated counts. + * + * The count is still checked against the stock shown as counting began + * (`checked_qty`): only "Apply anyway" applies it over the change, and + * applying again after cancelling asks again. + * + * @param {Object[]} changed the products and lots whose stock changed + * @param {String} signature the count the question is about + */ + showStockChanges(changed, signature) { + for (const change of changed) { + for (const line of this.state.lines) { + if ( + line.product_id === change.product_id && + (line.lot_name || "") === (change.lot_name || "") + ) { + line.theoretical_qty = change.now; + } + } + } + const rows = changed.map((c) => { + // Built apart from the _t() call: a template literal among its + // arguments hides the message from Odoo's translation extractor. + const what = c.lot_name + ? c.product_name + " (" + c.lot_name + ")" + : c.product_name; + return _t("• %(what)s: %(shown)s %(uom)s when counting, %(now)s now", { + what, + shown: this.formatQty(c.shown), + now: this.formatQty(c.now), + uom: c.uom, + }); + }); + this.inventory.openDialog(ConfirmationDialog, { + title: _t("Stock changed"), + body: [ + _t("The stock changed here while you counted:"), + ...rows, + "", + _t( + "Your counted quantities will be set all the same. Apply the count?" + ), + ].join("\n"), + confirmLabel: _t("Apply anyway"), + confirm: () => this.apply(true, signature), + cancel: () => undefined, + }); + } + + goBack() { + this.store.goBack(); + } + + static template = "barcode_inventory.InventoryCountScreen"; +} + +barcodeScreens.add("inventory_count", {component: InventoryCountScreen}); diff --git a/barcode_inventory/static/src/js/screens/inventory_location_screen.esm.js b/barcode_inventory/static/src/js/screens/inventory_location_screen.esm.js new file mode 100644 index 0000000..32f91ff --- /dev/null +++ b/barcode_inventory/static/src/js/screens/inventory_location_screen.esm.js @@ -0,0 +1,103 @@ +import {Component, onWillStart, useState} from "@odoo/owl"; +import {_t} from "@web/core/l10n/translation"; +import {barcodeMatchDomain} from "@barcode_scanner/js/utils/scan_match.esm"; +import {barcodeScreens} from "@barcode_scanner/js/registries.esm"; +import {useBarcodeHandler} from "@barcode_scanner/js/hooks/use_barcode_handler.esm"; +import {useBarcodeScanner} from "@barcode_scanner/js/hooks/use_inventory.esm"; +import {useService} from "@web/core/utils/hooks"; +import {user} from "@web/core/user"; + +/** + * First step of an inventory adjustment: pick or scan the internal location to + * count. Scanning a location barcode jumps straight into the count; otherwise + * the operator picks one from the searchable list. + * + * Odoo lets only inventory administrators apply counted quantities, so anyone + * else is told so here, before counting anything. + */ +export class InventoryLocationScreen extends Component { + setup() { + this.inventory = useBarcodeScanner(); + this.store = useService("barcodeStore"); + this.state = useState({ + locations: [], + search: "", + loading: true, + canApply: true, + }); + + useBarcodeHandler({ + onScan: async (barcode, parsedData) => { + await this.onBarcodeScanned(barcode, parsedData); + }, + }); + + onWillStart(() => this.loadScreen()); + } + + async loadScreen() { + this.state.canApply = await user.hasGroup("stock.group_stock_manager"); + if (this.state.canApply) { + await this.loadLocations(); + } else { + this.state.loading = false; + } + } + + async loadLocations() { + this.state.locations = await this.inventory.searchRead( + "stock.location", + [["usage", "=", "internal"]], + ["id", "display_name"] + ); + this.state.loading = false; + } + + async onBarcodeScanned(barcode, parsedData) { + if (!this.state.canApply) { + return; + } + const code = parsedData?.value || barcode; + const domain = barcodeMatchDomain(code); + const locations = domain + ? await this.inventory.searchRead( + "stock.location", + [...domain, ["usage", "=", "internal"]], + ["id", "display_name"] + ) + : []; + if (locations.length) { + this.selectLocation(locations[0]); + return; + } + this.state.search = code; + this.inventory.notify(_t("No internal location matches “%(code)s”.", {code}), { + type: "warning", + }); + } + + get filteredLocations() { + if (!this.state.search) { + return this.state.locations; + } + const search = this.state.search.toLowerCase(); + return this.state.locations.filter((loc) => + loc.display_name.toLowerCase().includes(search) + ); + } + + selectLocation(location) { + this.store.navigate("inventory_count", { + locationId: location.id, + locationName: location.display_name, + }); + } + + goBack() { + this.store.goBack(); + } + + static template = "barcode_inventory.InventoryLocationScreen"; +} + +barcodeScreens.add("inventory_location", {component: InventoryLocationScreen}); diff --git a/barcode_inventory/static/src/js/screens/inventory_product_selector_screen.esm.js b/barcode_inventory/static/src/js/screens/inventory_product_selector_screen.esm.js new file mode 100644 index 0000000..13c3563 --- /dev/null +++ b/barcode_inventory/static/src/js/screens/inventory_product_selector_screen.esm.js @@ -0,0 +1,130 @@ +import { + COUNTABLE_PRODUCTS, + notStorableMessage, +} from "@barcode_inventory/js/screens/inventory_count_screen.esm"; +import {Component, onWillStart, useState} from "@odoo/owl"; +import {_t} from "@web/core/l10n/translation"; +import {barcodeMatchDomain} from "@barcode_scanner/js/utils/scan_match.esm"; +import {barcodeScreens} from "@barcode_scanner/js/registries.esm"; +import {useBarcodeHandler} from "@barcode_scanner/js/hooks/use_barcode_handler.esm"; +import {useBarcodeScanner} from "@barcode_scanner/js/hooks/use_inventory.esm"; +import {useService} from "@web/core/utils/hooks"; + +/** + * Product selector for the inventory count, opened from the count screen's "+" + * -- like the back office, so a product not yet in the location can be added by + * searching (or scanning) it. It carries the count in progress through the + * navigation params and hands the picked product back to the count screen. + */ +export class InventoryProductSelectorScreen extends Component { + setup() { + this.inventory = useBarcodeScanner(); + this.store = useService("barcodeStore"); + this.state = useState({ + search: "", + results: [], + loading: true, + }); + + useBarcodeHandler({ + onScan: async (barcode, parsedData) => { + await this.onBarcodeScanned(barcode, parsedData); + }, + }); + + onWillStart(async () => { + await this.search(); + }); + } + + async search() { + // Searches run as the operator types: only the latest one may fill the + // list, whatever order the answers come back in. + this._searchSeq = (this._searchSeq || 0) + 1; + const query = this._searchSeq; + const term = this.state.search; + const exclude = this.props.params?.excludeProductIds || []; + const domain = [...COUNTABLE_PRODUCTS]; + if (term) { + domain.push("|", ["name", "ilike", term], ["barcode", "ilike", term]); + } + if (exclude.length) { + // Hide the untracked products the count already lists. + domain.push(["id", "not in", exclude]); + } + const results = await this.inventory.searchRead( + "product.product", + domain, + ["id", "display_name", "tracking", "uom_id"], + {limit: 50} + ); + if (query !== this._searchSeq) { + return; + } + this.state.results = results; + this.state.loading = false; + } + + onSearchInput(ev) { + this.state.search = ev.target.value; + this.search(); + } + + async onBarcodeScanned(barcode, parsed) { + const code = parsed?.gtin || parsed?.value || barcode; + const domain = barcodeMatchDomain(code); + const products = domain + ? await this.inventory.searchRead("product.product", domain, [ + "id", + "display_name", + "tracking", + "uom_id", + "is_storable", + ]) + : []; + if (!products.length) { + this.inventory.notify(_t("No product matches “%(code)s”.", {code}), { + type: "warning", + }); + return; + } + const product = products.find((p) => p.is_storable); + if (!product) { + this.inventory.notify(notStorableMessage(products[0]), {type: "warning"}); + return; + } + this.pickProduct(product); + } + + backParams() { + const params = this.props.params || {}; + return { + locationId: params.locationId, + locationName: params.locationName, + lines: params.lines || [], + }; + } + + pickProduct(product) { + this.store.goBack({ + ...this.backParams(), + addProduct: { + id: product.id, + display_name: product.display_name, + tracking: product.tracking, + uom_id: product.uom_id, + }, + }); + } + + goBack() { + // Return without adding, keeping the count in progress intact. + this.store.goBack(this.backParams()); + } + + static template = "barcode_inventory.InventoryProductSelectorScreen"; +} + +barcodeScreens.add("inventory_product_selector", { + component: InventoryProductSelectorScreen, +}); diff --git a/barcode_inventory/static/src/js/tiles/inventory_menu_tiles.esm.js b/barcode_inventory/static/src/js/tiles/inventory_menu_tiles.esm.js new file mode 100644 index 0000000..77e3a07 --- /dev/null +++ b/barcode_inventory/static/src/js/tiles/inventory_menu_tiles.esm.js @@ -0,0 +1,18 @@ +import {_t} from "@web/core/l10n/translation"; +import {barcodeMenuTiles} from "@barcode_scanner/js/registries.esm"; + +/** + * Home-screen tile for inventory adjustments. Registers into the scanner core + * without patching it, like every other feature module. + */ + +barcodeMenuTiles.add( + "inventory_adjustment", + { + label: _t("Inventory Adjustment"), + icon: "fa-balance-scale", + iconClass: "ilx-icon-adjustment", + action: ({navigate}) => navigate("inventory_location"), + }, + {sequence: 40} +); diff --git a/barcode_inventory/static/src/scss/inventory.scss b/barcode_inventory/static/src/scss/inventory.scss new file mode 100644 index 0000000..9aa4576 --- /dev/null +++ b/barcode_inventory/static/src/scss/inventory.scss @@ -0,0 +1,33 @@ +// Count screens. Reuses the shared design tokens (--ilx-*) defined by +// barcode_scanner so the look matches the rest of the suite. + +// Difference between the counted quantity and the stock, under the count. +.ilx-inv-diff { + font-size: 0.8rem; + font-weight: 600; + + &.ilx-inv-diff-up { + color: var(--ilx-success); + } + + &.ilx-inv-diff-down { + color: var(--ilx-error); + } + + &.ilx-inv-diff-none { + color: var(--ilx-on-surface-variant); + font-weight: normal; + } +} + +// Shown instead of the locations to an operator who cannot apply a count. +.ilx-inv-no-access { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-radius: 12px; + background: var(--ilx-warning-bg); + color: var(--ilx-on-surface); + font-size: 0.9rem; +} diff --git a/barcode_inventory/static/src/xml/inventory_templates.xml b/barcode_inventory/static/src/xml/inventory_templates.xml new file mode 100644 index 0000000..68e2363 --- /dev/null +++ b/barcode_inventory/static/src/xml/inventory_templates.xml @@ -0,0 +1,270 @@ + + + + +
+
+ +

Inventory Adjustment

+
+ +
+
+ + Only inventory administrators can apply a count. +
+ + +

SELECT A LOCATION TO COUNT

+
+ +
Loading locations...
+
+ + +
+
+
+
+ +
+
+
+
+ + + +
+
+ +
+
+
+
No internal locations found.
+
+
+
+
+
+ + +
+
+ +

+ +

+
+ +
+ + + +

+ COUNT — SCAN OR TYPE THE COUNTED QTY +

+ +
+ +
Loading stock...
+
+ + +
+
+ + + + + + + · + + On hand: + + + + + + + + + + + +
+
+
+ + +
+ +
+
+
+
No stock here yet — scan or add a product.
+
+
+
+ +
+ +
+
+
+ + +
+
+ +

Add Product

+
+ +
+ + +

PRODUCTS

+
+ +
Loading products...
+
+ + +
+
+ + + +
+
+ +
+
+
+
No products found.
+
+
+
+
+
+ +
diff --git a/barcode_inventory/static/tests/inventory_count.test.js b/barcode_inventory/static/tests/inventory_count.test.js new file mode 100644 index 0000000..4ef082c --- /dev/null +++ b/barcode_inventory/static/tests/inventory_count.test.js @@ -0,0 +1,711 @@ +import { + COUNTABLE_PRODUCTS, + InventoryCountScreen, +} from "@barcode_inventory/js/screens/inventory_count_screen.esm"; +import {beforeEach, describe, expect, test} from "@odoo/hoot"; +import { + makeMockEnv, + patchTranslations, + patchWithCleanup, +} from "@web/../tests/web_test_helpers"; +import {InventoryLocationScreen} from "@barcode_inventory/js/screens/inventory_location_screen.esm"; +import {InventoryProductSelectorScreen} from "@barcode_inventory/js/screens/inventory_product_selector_screen.esm"; +import {getTemplate} from "@web/core/templates"; +import {parseBarcode} from "@barcode_scanner/js/barcode_parser.esm"; +import {user} from "@web/core/user"; + +const UNTRACKED = { + id: 7, + display_name: "Bolts", + tracking: "none", + uom_id: [1, "Units"], + is_storable: true, +}; +const BY_LOT = { + id: 8, + display_name: "Cheese", + tracking: "lot", + uom_id: [12, "kg"], + is_storable: true, +}; +const BY_SERIAL = { + id: 9, + display_name: "Drill", + tracking: "serial", + uom_id: [1, "Units"], + is_storable: true, +}; +// A product with no stock to count. +const SERVICE = { + id: 20, + display_name: "Gift wrapping", + tracking: "none", + uom_id: [1, "Units"], + is_storable: false, +}; + +/** + * The fake services a screen talks to. What it tells the operator lands in + * `notes`, where it navigates in `moves`, what it searches for in `searches` + * (answered in turn with `found`, the last answer repeated), what it asks + * the server in `calls` (answered with `answer`, or in turn with `answers`; + * failing when the answer is an Error) and the dialogs it opens in `dialogs`. + * + * @param {Object} [options] the answers to give + * @returns {Object} the fakes and what they recorded + */ +function fakes({found = [[]], answer = {}, answers = null} = {}) { + const notes = []; + const moves = []; + const searches = []; + const calls = []; + const dialogs = []; + const inventory = { + notify: (message, options) => + notes.push({message: String(message), ...options}), + searchRead: async (model, domain) => { + searches.push({model, domain}); + return found[Math.min(searches.length, found.length) - 1]; + }, + call: async (model, method, args, kwargs) => { + calls.push(kwargs ? {model, method, args, kwargs} : {model, method, args}); + const reply = answers ? answers[calls.length - 1] : answer; + if (reply instanceof Error) { + throw reply; + } + return reply; + }, + openDialog: (component, props) => dialogs.push(props), + }; + const store = { + navigate: (route, params, options) => moves.push({route, params, options}), + goBack: (params) => moves.push({route: "back", params}), + }; + return {inventory, store, notes, moves, searches, calls, dialogs}; +} + +/** + * A count screen holding `lines`, without mounting it. + * + * @param {Object} [options] the lines and the fakes' answers + * @returns {Object} the screen and what its fakes recorded + */ +function countScreen({lines = [], ...answers} = {}) { + const screen = Object.create(InventoryCountScreen.prototype); + const recorded = fakes(answers); + screen._seq = lines.length; + screen.state = { + locationId: 3, + locationName: "WH/Stock/Shelf", + lines, + loading: false, + applying: false, + }; + screen.inventory = recorded.inventory; + screen.store = recorded.store; + screen.flashLine = () => undefined; + return {screen, ...recorded}; +} + +function stockLine(values) { + return { + _id: 1, + lot_id: false, + lot_name: "", + theoretical_qty: 0, + uom: "Units", + lot_fixed: false, + lot_options: [], + counted: null, + isNew: false, + ...values, + }; +} + +describe("BarcodeInventory", () => { + beforeEach(() => patchTranslations()); + + test("the same product and lot add up on one line, the newest on top", async () => { + const {screen} = countScreen(); + await screen.addOrIncrement(UNTRACKED, null, 1); + await screen.addOrIncrement(UNTRACKED, null, 2); + expect(screen.state.lines.length).toBe(1); + expect(screen.state.lines[0].counted).toBe("3"); + await screen.addOrIncrement(BY_LOT, "LOT-9", 1); + expect(screen.state.lines.map((l) => l.product_id)).toEqual([8, 7]); + expect(screen.state.lines[0]).toMatchObject({ + lot_name: "LOT-9", + lot_fixed: true, + counted: "1", + uom: "kg", + isNew: true, + }); + }); + + test("a product added from the selector is listed, not counted", async () => { + const {screen, calls} = countScreen({answer: ["LOT-1"]}); + await screen.addOrIncrement(BY_LOT, null, null); + expect(screen.state.lines[0]).toMatchObject({counted: null, lot_fixed: false}); + // Its lot is still to be set: the lots a count here can use are + // suggested, as the server finds them. + expect(calls).toEqual([ + { + model: "stock.inventory", + method: "action_barcode_lot_names", + args: [3, 8, ""], + }, + ]); + expect(screen.state.lines[0].lot_options).toEqual(["LOT-1"]); + // Picking it again leaves its line as it is. + await screen.addOrIncrement(BY_LOT, null, null); + expect(screen.state.lines.length).toBe(1); + expect(screen.state.lines[0].counted).toBe(null); + }); + + test("a serial number counts one unit, once", async () => { + const {screen, notes} = countScreen(); + await screen.addOrIncrement(BY_SERIAL, "SN-1", 3); + await screen.addOrIncrement(BY_SERIAL, "SN-1", 1); + expect(screen.state.lines.length).toBe(1); + expect(screen.state.lines[0].counted).toBe("1"); + // Without its serial number yet, the line stands for one unit too. + await screen.addOrIncrement(BY_SERIAL, null, 1); + await screen.addOrIncrement(BY_SERIAL, null, 1); + expect(screen.state.lines[0].counted).toBe("1"); + expect(notes).toEqual([ + {message: "Serial number SN-1 is already counted.", type: "warning"}, + { + message: + "Drill is already counted: set its serial number before counting another one.", + type: "warning", + }, + ]); + }); + + test("scanning a serial number in stock counts it", async () => { + const {screen, notes} = countScreen({ + lines: [ + stockLine({ + product_id: 9, + tracking: "serial", + lot_name: "SN-1", + lot_fixed: true, + theoretical_qty: 1, + }), + ], + }); + await screen.addOrIncrement(BY_SERIAL, "SN-1", 1); + expect(screen.state.lines.length).toBe(1); + expect(screen.state.lines[0].counted).toBe("1"); + expect(notes).toEqual([]); + }); + + test("a scan looks for the product by every code it carries", async () => { + const {screen, searches} = countScreen({found: [[SERVICE, BY_LOT]]}); + await screen.onBarcodeScanned("(01)0123(10)L1(30)4", { + gtin: "0123", + value: "0123", + lot: "L1", + qty: 4, + }); + const [{domain}] = searches; + expect(JSON.stringify(domain)).toInclude("0123"); + expect(JSON.stringify(domain)).toInclude("(01)0123(10)L1(30)4"); + // The storable one among those it matches is counted. + expect(screen.state.lines.length).toBe(1); + expect(screen.state.lines[0]).toMatchObject({ + product_id: 8, + lot_name: "L1", + counted: "4", + }); + }); + + test("a scanned product with no stock to count is refused, saying why", async () => { + const {screen, notes} = countScreen({found: [[SERVICE]]}); + await screen.onBarcodeScanned("777", {value: "777"}); + expect(screen.state.lines).toEqual([]); + expect(notes).toEqual([ + { + message: + "Gift wrapping is not a storable product: it has no stock to count.", + type: "warning", + }, + ]); + }); + + test("a scan matching no product says so", async () => { + const {screen, notes} = countScreen(); + await screen.onBarcodeScanned("999", {value: "999"}); + expect(screen.state.lines).toEqual([]); + expect(notes).toEqual([ + {message: "No product matches “999”.", type: "warning"}, + ]); + }); + + test("a plain EAN-13, as the core parser reads it, counts one unit", async () => { + const {screen, searches} = countScreen({found: [[UNTRACKED]]}); + const parsed = parseBarcode("4006381333931"); + await screen.onBarcodeScanned("4006381333931", parsed); + expect(JSON.stringify(searches[0].domain)).toInclude("4006381333931"); + expect(screen.state.lines[0]).toMatchObject({ + product_id: 7, + lot_name: "", + counted: "1", + }); + }); + + test("a scan that resolves while the count is applied changes nothing", async () => { + const {screen, notes} = countScreen(); + let resolve = null; + screen.inventory.searchRead = () => + new Promise((done) => { + resolve = done; + }); + const scan = screen.onBarcodeScanned("0123", {value: "0123"}); + // Apply is pressed while the product is looked up. + screen.state.applying = true; + resolve([UNTRACKED]); + await scan; + expect(screen.state.lines).toEqual([]); + expect(notes).toEqual([ + { + message: "Wait until the count is applied to scan again.", + type: "warning", + }, + ]); + }); + + test("the count is sent once, and stays as sent meanwhile", async () => { + const line = stockLine({product_id: 7, tracking: "none", counted: "2"}); + const lot = stockLine({_id: 2, product_id: 8, tracking: "lot", lot_name: "L"}); + const {screen, calls, moves} = countScreen({lines: [line, lot]}); + let resolve = null; + screen.inventory.call = (model, method, args) => { + calls.push({model, method, args}); + return new Promise((done) => { + resolve = done; + }); + }; + const sending = screen.apply(); + await screen.apply(); + expect(calls.length).toBe(1); + // While it is on its way, nothing changes the lines it sent. + screen.setCounted(line, "5"); + screen.setLotName(lot, "OTHER"); + screen.removeLine(line); + screen.addProduct(); + expect(screen.state.lines.map((l) => [l.counted, l.lot_name])).toEqual([ + ["2", ""], + [null, "L"], + ]); + resolve({done: true, adjusted: 1}); + await sending; + expect(moves.map((m) => m.route)).toEqual(["main"]); + }); + + test("a scan stands for its piece count, or one unit", () => { + const {screen} = countScreen(); + expect(screen.scannedQuantity({qty: 6})).toBe(6); + expect(screen.scannedQuantity({quantity: "2.5"})).toBe(2.5); + expect(screen.scannedQuantity({qty: null, quantity: null})).toBe(1); + expect(screen.scannedQuantity(undefined)).toBe(1); + // A label stating zero counts zero; one stating no quantity, nothing. + expect(screen.scannedQuantity({qty: 0})).toBe(0); + expect(screen.scannedQuantity({qty: "abc"})).toBe(null); + expect(screen.scannedQuantity({qty: Infinity})).toBe(null); + expect(screen.scannedQuantity({qty: -2})).toBe(null); + }); + + test("scans wait while the count is being applied", async () => { + const {screen, notes, searches} = countScreen(); + screen.state.applying = true; + await screen.onBarcodeScanned("0123", {value: "0123"}); + expect(searches).toEqual([]); + expect(notes).toEqual([ + { + message: "Wait until the count is applied to scan again.", + type: "warning", + }, + ]); + }); + + test("each line shows how far the count is from the stock", async () => { + await makeMockEnv(); + const {screen} = countScreen(); + const line = stockLine({product_id: 7, tracking: "none", theoretical_qty: 0.1}); + expect(screen.diff(line)).toBe(null); + screen.setCounted(line, "0.3"); + expect(screen.formatDiff(screen.diff(line))).toBe("+0.2"); + expect(screen.diffClass(line)).toBe("ilx-inv-diff-up"); + screen.setCounted(line, "0"); + expect(screen.formatDiff(screen.diff(line))).toBe("-0.1"); + expect(screen.diffClass(line)).toBe("ilx-inv-diff-down"); + screen.setCounted(line, "0.1"); + expect(screen.formatDiff(screen.diff(line))).toBe("0"); + expect(screen.diffClass(line)).toBe("ilx-inv-diff-none"); + screen.setCounted(line, ""); + expect(screen.isCounted(line)).toBe(false); + expect(screen.formatQty(10)).toBe("10"); + }); + + test("applying sends the counted lines only", async () => { + const counted = stockLine({ + _id: 1, + product_id: 8, + tracking: "lot", + lot_name: "LOT-1", + counted: "4", + }); + const untouched = stockLine({_id: 2, product_id: 7, tracking: "none"}); + const {screen, calls, notes, moves} = countScreen({ + lines: [counted, untouched], + answer: {done: true, adjusted: 1}, + }); + expect(screen.countedLines).toEqual([counted]); + await screen.apply(); + expect(calls).toEqual([ + { + model: "stock.inventory", + method: "action_barcode_apply_count", + args: [ + 3, + [ + { + product_id: 8, + lot_name: "LOT-1", + counted_qty: 4, + theoretical_qty: 0, + }, + ], + ], + kwargs: {force: false}, + }, + ]); + expect(notes).toEqual([ + {message: "Inventory adjusted: 1 product(s).", type: "success"}, + ]); + expect(moves).toEqual([ + {route: "main", params: {}, options: {clearHistory: true}}, + ]); + }); + + test("nothing is applied without a count, or a lot where one is needed", async () => { + const {screen, calls, notes} = countScreen({ + lines: [ + stockLine({ + product_id: 8, + product_name: "Cheese", + tracking: "lot", + lot_name: " ", + }), + ], + }); + await screen.apply(); + screen.state.lines[0].counted = "2"; + await screen.apply(); + expect(calls).toEqual([]); + expect(notes.map((n) => n.message)).toEqual([ + "Count at least one product before applying.", + "Set the lot/serial for Cheese before applying.", + ]); + }); + + test("a quantity that is not a number is not sent", async () => { + const {screen, calls, notes} = countScreen({ + lines: [ + stockLine({ + product_id: 7, + product_name: "Bolts", + tracking: "none", + counted: "1e309", + }), + ], + }); + await screen.apply(); + expect(calls).toEqual([]); + expect(notes.map((n) => n.message)).toEqual([ + "The counted quantity for Bolts is not a number.", + ]); + }); + + test("stock changed while counting: shown, asked, forced only as confirmed", async () => { + await makeMockEnv(); + const line = stockLine({ + product_id: 7, + product_name: "Bolts", + tracking: "none", + theoretical_qty: 10, + counted: "8", + }); + const changed = { + done: false, + changed: [ + { + product_id: 7, + product_name: "Bolts", + lot_name: "", + shown: 10, + now: 15, + uom: "Units", + }, + ], + }; + const {screen, calls, dialogs, moves} = countScreen({ + lines: [line], + answers: [changed, {done: true, adjusted: 1}, changed], + }); + await screen.apply(); + // Nothing applied: the line shows the stock as it is now, and the + // operator is asked. + expect(calls[0].kwargs).toEqual({force: false}); + expect(calls[0].args[1][0].theoretical_qty).toBe(10); + expect(line.theoretical_qty).toBe(15); + expect(screen.state.applying).toBe(false); + expect(dialogs.length).toBe(1); + expect(dialogs[0].body).toInclude("Bolts: 10 Units when counting, 15 now"); + // Confirmed as it stands, the count is forced. + await dialogs[0].confirm(); + expect(calls[1].kwargs).toEqual({force: true}); + expect(moves.map((m) => m.route)).toEqual(["main"]); + // Had the count changed since the question, it is checked again. + screen.state.applying = false; + await screen.apply(true, "another count"); + expect(calls[2].kwargs).toEqual({force: false}); + }); + + test("cancelling the stock warning and applying again asks again", async () => { + await makeMockEnv(); + const line = stockLine({ + product_id: 7, + product_name: "Bolts", + tracking: "none", + theoretical_qty: 10, + checked_qty: 10, + counted: "8", + }); + const changed = { + done: false, + changed: [ + { + product_id: 7, + product_name: "Bolts", + lot_name: "", + shown: 10, + now: 15, + uom: "Units", + }, + ], + }; + const {screen, calls, dialogs} = countScreen({ + lines: [line], + answers: [changed, changed], + }); + await screen.apply(); + // The operator cancels: the line shows the stock as it is now... + expect(line.theoretical_qty).toBe(15); + // ...but applying again is still checked against the stock shown as + // counting began, so the server warns again. + await screen.apply(); + expect(calls.map((c) => c.args[1][0].theoretical_qty)).toEqual([10, 10]); + expect(calls.map((c) => c.kwargs)).toEqual([{force: false}, {force: false}]); + expect(dialogs.length).toBe(2); + }); + + test("a scan stating zero counts zero, an unreadable one nothing", async () => { + const {screen, notes} = countScreen({found: [[UNTRACKED], [BY_LOT]]}); + await screen.onBarcodeScanned("0123", {value: "0123", qty: 0}); + expect(screen.state.lines[0]).toMatchObject({product_id: 7, counted: "0"}); + await screen.onBarcodeScanned("0456", {value: "0456", qty: "x"}); + expect(screen.state.lines.length).toBe(1); + expect(notes.map((n) => n.message)).toEqual([ + "The quantity on the label of Cheese cannot be read.", + ]); + }); + + test("the lot field looks lots up as the operator types", async () => { + const line = stockLine({product_id: 8, tracking: "lot"}); + const {screen, calls} = countScreen({lines: [line]}); + const pending = []; + screen.inventory.call = (model, method, args) => { + calls.push(args); + return new Promise((done) => pending.push(done)); + }; + screen.setLotName(line, "L"); + screen.setLotName(line, "LO"); + expect(line.lot_name).toBe("LO"); + expect(calls).toEqual([ + [3, 8, "L"], + [3, 8, "LO"], + ]); + // Only the latest answer fills the list. + pending[1](["LOT-1", "LOT-2"]); + pending[0](["L-OLD"]); + await Promise.resolve(); + await Promise.resolve(); + expect(line.lot_options).toEqual(["LOT-1", "LOT-2"]); + }); + + test("a refused count can be applied again", async () => { + const {screen, moves} = countScreen({ + lines: [stockLine({product_id: 7, tracking: "none", counted: "1"})], + answer: new Error("refused"), + }); + await screen.apply(); + expect(screen.state.applying).toBe(false); + expect(moves).toEqual([]); + }); + + test("the lines and the location are loaded from the server", async () => { + const {screen, calls} = countScreen({ + answer: { + lines: [ + { + product_id: 8, + tracking: "lot", + lot_name: "LOT-1", + theoretical_qty: 5, + }, + { + product_id: 8, + tracking: "lot", + lot_name: false, + theoretical_qty: 1, + }, + ], + }, + }); + await screen.loadLines(); + expect(calls[0].method).toBe("action_barcode_location_lines"); + expect(screen.state.lines.map((l) => [l._id, l.lot_fixed, l.counted])).toEqual([ + [1, true, null], + [2, false, null], + ]); + expect(screen.state.lines.map((l) => l.checked_qty)).toEqual([5, 1]); + expect(screen.needsLotInput(screen.state.lines[1])).toBe(true); + screen.setLotName(screen.state.lines[1], "LOT-2"); + expect(screen.state.lines[1].lot_name).toBe("LOT-2"); + screen.removeLine(screen.state.lines[0]); + expect(screen.state.lines.map((l) => l._id)).toEqual([2]); + }); + + test("the + picker keeps the count and leaves out the untracked listed", () => { + const {screen, moves} = countScreen({ + lines: [ + stockLine({_id: 1, product_id: 7, tracking: "none", counted: "2"}), + stockLine({_id: 2, product_id: 8, tracking: "lot", lot_name: "L"}), + ], + }); + screen.addProduct(); + expect(moves[0].route).toBe("inventory_product_selector"); + expect(moves[0].params).toMatchObject({ + locationId: 3, + locationName: "WH/Stock/Shelf", + excludeProductIds: [7], + }); + expect(moves[0].params.lines.map((l) => l.counted)).toEqual(["2", null]); + }); + + test("the selector shows only the latest search, of countable products", async () => { + const screen = Object.create(InventoryProductSelectorScreen.prototype); + const {inventory, store, searches, moves} = fakes(); + let answer = null; + const pending = []; + inventory.searchRead = (model, domain) => { + searches.push(domain); + return new Promise((resolve) => pending.push(() => resolve(answer))); + }; + Object.assign(screen, { + inventory, + store, + props: {params: {locationId: 3, lines: [], excludeProductIds: [7]}}, + state: {search: "bo", results: [], loading: true}, + }); + const first = screen.search(); + screen.state.search = "bol"; + const second = screen.search(); + expect(searches[1]).toEqual([ + ...COUNTABLE_PRODUCTS, + "|", + ["name", "ilike", "bol"], + ["barcode", "ilike", "bol"], + ["id", "not in", [7]], + ]); + answer = [UNTRACKED]; + pending[1](); + await second; + answer = [BY_LOT]; + pending[0](); + await first; + expect(screen.state.results).toEqual([UNTRACKED]); + screen.pickProduct(UNTRACKED); + screen.goBack(); + expect(moves.map((m) => m.params.addProduct?.id)).toEqual([7, undefined]); + expect(moves[1].params).toMatchObject({locationId: 3, lines: []}); + }); + + test("the selector refuses a scanned product with no stock to count", async () => { + const screen = Object.create(InventoryProductSelectorScreen.prototype); + const {inventory, store, notes, moves} = fakes({ + found: [[SERVICE], [UNTRACKED]], + }); + Object.assign(screen, {inventory, store, props: {params: {lines: []}}}); + await screen.onBarcodeScanned("777", {value: "777"}); + expect(moves).toEqual([]); + expect(notes.map((n) => n.message)).toEqual([ + "Gift wrapping is not a storable product: it has no stock to count.", + ]); + await screen.onBarcodeScanned("123", {value: "123"}); + expect(moves.map((m) => m.params.addProduct?.id)).toEqual([7]); + }); + + test("operators who cannot apply a count are told so first", async () => { + patchWithCleanup(user, {hasGroup: async () => false}); + const screen = Object.create(InventoryLocationScreen.prototype); + const {inventory, store, searches} = fakes({ + found: [[{id: 4, display_name: "Shelf"}]], + }); + Object.assign(screen, { + inventory, + store, + state: {locations: [], search: "", loading: true, canApply: true}, + }); + await screen.loadScreen(); + expect(screen.state).toMatchObject({canApply: false, loading: false}); + await screen.onBarcodeScanned("SHELF", {value: "SHELF"}); + expect(searches).toEqual([]); + }); + + test("the count screen locks while applying and takes decimal counts", () => { + const html = getTemplate("barcode_inventory.InventoryCountScreen").outerHTML; + expect(html).toInclude('step="any"'); + expect(html.split('t-att-disabled="state.applying"').length - 1).toBe(4); + }); + + test("an inventory administrator picks or scans the location", async () => { + patchWithCleanup(user, {hasGroup: async () => true}); + const screen = Object.create(InventoryLocationScreen.prototype); + const shelf = {id: 4, display_name: "WH/Stock/Shelf"}; + const {inventory, store, notes, moves} = fakes({ + found: [[shelf, {id: 5, display_name: "WH/Stock/Bin"}], [shelf], []], + }); + Object.assign(screen, { + inventory, + store, + state: {locations: [], search: "", loading: true, canApply: true}, + }); + await screen.loadScreen(); + expect(screen.state.locations.length).toBe(2); + screen.state.search = "bin"; + expect(screen.filteredLocations.map((l) => l.id)).toEqual([5]); + await screen.onBarcodeScanned("SHELF", {value: "SHELF"}); + expect(moves[0]).toMatchObject({ + route: "inventory_count", + params: {locationId: 4, locationName: "WH/Stock/Shelf"}, + }); + await screen.onBarcodeScanned("NOPE", {value: "NOPE"}); + expect(screen.state.search).toBe("NOPE"); + expect(notes).toEqual([ + {message: "No internal location matches “NOPE”.", type: "warning"}, + ]); + }); +}); diff --git a/barcode_inventory/tests/__init__.py b/barcode_inventory/tests/__init__.py new file mode 100644 index 0000000..b87a369 --- /dev/null +++ b/barcode_inventory/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_barcode_inventory +from . import test_inventory_count +from . import test_js diff --git a/barcode_inventory/tests/test_barcode_inventory.py b/barcode_inventory/tests/test_barcode_inventory.py new file mode 100644 index 0000000..7b38b1a --- /dev/null +++ b/barcode_inventory/tests/test_barcode_inventory.py @@ -0,0 +1,52 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestBarcodeInventory(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Inventory = cls.env["stock.inventory"] + cls.location = cls.env["stock.location"].create( + { + "name": "Barcode Count Shelf", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_stock").id, + } + ) + cls.storable = cls.env["product.product"].create( + {"name": "Count Storable", "is_storable": True} + ) + cls.consumable = cls.env["product.product"].create( + {"name": "Count Consumable", "type": "consu", "is_storable": False} + ) + cls.service = cls.env["product.product"].create( + {"name": "Count Service", "type": "service"} + ) + + def test_apply_count_storable(self): + result = self.Inventory.action_barcode_apply_count( + self.location.id, + [{"product_id": self.storable.id, "counted_qty": 5}], + ) + self.assertEqual(result["adjusted"], 1) + self.assertEqual( + self.env["stock.quant"]._get_available_quantity( + self.storable, self.location + ), + 5, + ) + + def test_apply_count_refuses_non_storable(self): + for product in (self.consumable, self.service): + with ( + self.subTest(product=product.name), + self.assertRaisesRegex(UserError, "not a storable product"), + ): + self.Inventory.action_barcode_apply_count( + self.location.id, + [{"product_id": product.id, "counted_qty": 1}], + ) diff --git a/barcode_inventory/tests/test_inventory_count.py b/barcode_inventory/tests/test_inventory_count.py new file mode 100644 index 0000000..0c3125f --- /dev/null +++ b/barcode_inventory/tests/test_inventory_count.py @@ -0,0 +1,537 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo import Command +from odoo.exceptions import AccessError, UserError +from odoo.tests.common import TransactionCase, new_test_user + + +class TestBarcodeInventoryCount(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Inventory = cls.env["stock.inventory"] + cls.Quant = cls.env["stock.quant"] + cls.company = cls.env.company + cls.location = cls.env["stock.location"].create( + { + "name": "Barcode Count Shelf", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_stock").id, + "company_id": cls.company.id, + } + ) + cls.sublocation = cls.env["stock.location"].create( + { + "name": "Barcode Count Bin", + "usage": "internal", + "location_id": cls.location.id, + "company_id": cls.company.id, + } + ) + Product = cls.env["product.product"] + cls.product = Product.create({"name": "Count Untracked", "is_storable": True}) + cls.lot_product = Product.create( + {"name": "Count Lot", "is_storable": True, "tracking": "lot"} + ) + cls.serial_product = Product.create( + {"name": "Count Serial", "is_storable": True, "tracking": "serial"} + ) + cls.new_product = Product.create({"name": "Count New", "is_storable": True}) + cls.consumable = Product.create({"name": "Count Consumable", "type": "consu"}) + cls.lot = cls.env["stock.lot"].create( + { + "name": "LOT-COUNT-1", + "product_id": cls.lot_product.id, + "company_id": cls.company.id, + } + ) + cls.serial = cls.env["stock.lot"].create( + { + "name": "SN-COUNT-1", + "product_id": cls.serial_product.id, + "company_id": cls.company.id, + } + ) + cls.package = cls.env["stock.quant.package"].create({"name": "COUNT-PACK"}) + cls.owner = cls.env["res.partner"].create({"name": "Count Owner"}) + Quant = cls.Quant + Quant._update_available_quantity(cls.product, cls.location, 10) + Quant._update_available_quantity(cls.product, cls.sublocation, 3) + Quant._update_available_quantity( + cls.product, cls.location, 4, package_id=cls.package + ) + Quant._update_available_quantity( + cls.product, cls.location, 2, owner_id=cls.owner + ) + Quant._update_available_quantity( + cls.lot_product, cls.location, 5, lot_id=cls.lot + ) + Quant._update_available_quantity( + cls.serial_product, cls.location, 1, lot_id=cls.serial + ) + + def _qty(self, product, lot=None, location=None, package=None, owner=None): + """What a location holds of a product and lot, loose and unowned unless + a package or owner is given.""" + quants = self.Quant.search( + [ + ("product_id", "=", product.id), + ("location_id", "=", (location or self.location).id), + ("lot_id", "=", lot.id if lot else False), + ("package_id", "=", package.id if package else False), + ("owner_id", "=", owner.id if owner else False), + ] + ) + return sum(quants.mapped("quantity")) + + # --- the lines shown before counting -------------------------------------- + + def test_location_lines(self): + data = self.Inventory.action_barcode_location_lines(self.location.id) + self.assertEqual(data["location_id"], self.location.id) + self.assertEqual(data["location_name"], self.location.display_name) + lines = {(line["product_id"], line["lot_id"]): line for line in data["lines"]} + # Only the stock at the location itself, loose and unowned: not the + # sublocation, the package or the partner's. + self.assertEqual(lines[self.product.id, False]["theoretical_qty"], 10) + self.assertEqual(lines[self.product.id, False]["lot_name"], False) + self.assertEqual(lines[self.product.id, False]["uom"], self.product.uom_id.name) + lot_line = lines[self.lot_product.id, self.lot.id] + self.assertEqual(lot_line["lot_name"], "LOT-COUNT-1") + self.assertEqual(lot_line["tracking"], "lot") + self.assertEqual(lot_line["theoretical_qty"], 5) + self.assertEqual(len(data["lines"]), 3) + + def test_location_lines_one_per_product_and_lot(self): + # The same stock split over two quants, and stock that nets to zero. + self.Quant.sudo().create( + { + "product_id": self.lot_product.id, + "location_id": self.location.id, + "lot_id": self.lot.id, + "quantity": 2, + } + ) + for quantity in (3, -3): + self.Quant.sudo().create( + { + "product_id": self.new_product.id, + "location_id": self.location.id, + "quantity": quantity, + } + ) + data = self.Inventory.action_barcode_location_lines(self.location.id) + lots = [ + line for line in data["lines"] if line["product_id"] == self.lot_product.id + ] + self.assertEqual(len(lots), 1) + self.assertEqual(lots[0]["theoretical_qty"], 7) + self.assertNotIn( + self.new_product.id, [line["product_id"] for line in data["lines"]] + ) + + def test_location_lines_for_a_warehouse_user(self): + user = new_test_user( + self.env, login="barcode_count_user", groups="stock.group_stock_user" + ) + data = self.Inventory.with_user(user).action_barcode_location_lines( + self.location.id + ) + self.assertEqual(len(data["lines"]), 3) + + def test_invalid_location(self): + view = self.env["stock.location"].create( + {"name": "Barcode Count View", "usage": "view"} + ) + with self.assertRaises(UserError): + self.Inventory.action_barcode_location_lines(view.id) + with self.assertRaises(UserError): + self.Inventory.action_barcode_apply_count( + 0, [{"product_id": self.product.id}] + ) + + # --- lots ----------------------------------------------------------------- + + def test_lot_matched_exactly_then_in_another_case(self): + resolve = self.Inventory._barcode_resolve_lot + self.assertEqual( + resolve(self.lot_product, "LOT-COUNT-1", self.company), self.lot + ) + self.assertEqual( + resolve(self.lot_product, "lot-count-1", self.company), self.lot + ) + # Lots that differ only in case are distinct: the exact one wins, and + # a name matching several of them in another case is refused. + upper, lower = self.env["stock.lot"].create( + [ + {"name": "DUP", "product_id": self.lot_product.id}, + {"name": "dup", "product_id": self.lot_product.id}, + ] + ) + self.assertEqual(resolve(self.lot_product, "DUP", self.company), upper) + self.assertEqual(resolve(self.lot_product, "dup", self.company), lower) + with self.assertRaises(UserError): + resolve(self.lot_product, "Dup", self.company) + + def test_lot_name_wildcards_are_plain_characters(self): + lot = self.env["stock.lot"].create( + {"name": "LOT_A%", "product_id": self.lot_product.id} + ) + resolve = self.Inventory._barcode_resolve_lot + self.assertEqual(resolve(self.lot_product, "lot_a%", self.company), lot) + created = resolve(self.lot_product, "LOTXAB", self.company) + self.assertNotEqual(created, lot) + self.assertEqual(created.name, "LOTXAB") + + def test_new_lot_company(self): + other = self.env["res.company"].create({"name": "Barcode Count Co"}) + resolve = self.Inventory._barcode_resolve_lot + # A shared product gets the lot in the counted location's company... + lot = resolve(self.lot_product, "LOT-OTHER", other) + self.assertEqual(lot.company_id, other) + # ...and a company's product keeps it in its own. + own_product = self.env["product.product"].create( + { + "name": "Count Own Lot", + "is_storable": True, + "tracking": "lot", + "company_id": self.company.id, + } + ) + lot = resolve(own_product, "LOT-OWN", other) + self.assertEqual(lot.company_id, self.company) + + # --- checks before applying ----------------------------------------------- + + def test_lines_that_cannot_be_applied(self): + apply = self.Inventory.action_barcode_apply_count + cases = [ + [], + [{"product_id": 0, "counted_qty": 1}], + [{"product_id": self.consumable.id, "counted_qty": 1}], + [{"product_id": self.product.id, "counted_qty": -1}], + [{"product_id": self.product.id, "counted_qty": "twelve"}], + [{"product_id": self.product.id, "counted_qty": "nan"}], + [{"product_id": self.product.id, "counted_qty": "inf"}], + # An infinite number reaches the server as JSON null. + [{"product_id": self.product.id, "counted_qty": None}], + [{"product_id": self.lot_product.id, "lot_name": " ", "counted_qty": 1}], + [ + {"product_id": self.serial_product.id, "lot_name": "SN-COUNT-1"}, + { + "product_id": self.serial_product.id, + "lot_name": "SN-NEW", + "counted_qty": 2, + }, + ], + # The same serial number on two lines adds up to two units. + [ + { + "product_id": self.serial_product.id, + "lot_name": "SN-COUNT-1", + "counted_qty": 1, + }, + { + "product_id": self.serial_product.id, + "lot_name": "SN-COUNT-1", + "counted_qty": 1, + }, + ], + ] + for lines in cases: + with self.subTest(lines=lines), self.assertRaises(UserError): + apply(self.location.id, lines) + self.assertFalse( + self.Inventory.search([("location_ids", "=", self.location.id)]) + ) + + def test_only_inventory_administrators_apply(self): + user = new_test_user( + self.env, login="barcode_count_clerk", groups="stock.group_stock_user" + ) + with self.assertRaises(AccessError): + self.Inventory.with_user(user).action_barcode_apply_count( + self.location.id, [{"product_id": self.product.id, "counted_qty": 1}] + ) + + # --- applying ------------------------------------------------------------- + + def test_apply_count(self): + result = self.Inventory.action_barcode_apply_count( + self.location.id, + [ + {"product_id": self.product.id, "counted_qty": 7}, + { + "product_id": self.lot_product.id, + "lot_name": "LOT-COUNT-1", + "counted_qty": 4, + }, + { + "product_id": self.lot_product.id, + "lot_name": "lot-count-1", + "counted_qty": 2, + }, + { + "product_id": self.lot_product.id, + "lot_name": "LOT-NEW", + "counted_qty": 3, + }, + { + "product_id": self.serial_product.id, + "lot_name": "SN-COUNT-1", + "counted_qty": 0, + }, + {"product_id": self.new_product.id, "counted_qty": 8}, + ], + ) + new_lot = self.env["stock.lot"].search( + [("product_id", "=", self.lot_product.id), ("name", "=", "LOT-NEW")] + ) + self.assertEqual(new_lot.company_id, self.company) + self.assertEqual(self._qty(self.product), 7) + # Two lines of the same lot count the same stock: they add up. + self.assertEqual(self._qty(self.lot_product, self.lot), 6) + self.assertEqual(self._qty(self.lot_product, new_lot), 3) + self.assertEqual(self._qty(self.serial_product, self.serial), 0) + self.assertEqual(self._qty(self.new_product), 8) + # What the count does not cover is left as it was. + self.assertEqual(self._qty(self.product, location=self.sublocation), 3) + self.assertEqual(self._qty(self.product, package=self.package), 4) + self.assertEqual(self._qty(self.product, owner=self.owner), 2) + + group = self.Inventory.browse(result["group_id"]) + self.assertEqual(result["adjusted"], 5) + self.assertEqual(result["group_name"], group.name) + self.assertIn(self.location.display_name, group.name) + self.assertEqual(group.state, "done") + self.assertEqual(group.company_id, self.company) + self.assertEqual(group.location_ids, self.location) + self.assertTrue(group.exclude_sublocation) + self.assertEqual(group.product_selection, "manual") + self.assertEqual( + group.product_ids, + self.product | self.lot_product | self.serial_product | self.new_product, + ) + # One move per counted product and lot, the new stock included. + self.assertEqual(result["move_count"], 5) + self.assertEqual( + group.stock_move_ids.product_id, + self.product | self.lot_product | self.serial_product | self.new_product, + ) + self.assertEqual(set(group.stock_move_ids.mapped("state")), {"done"}) + # The adjustment holds the quants counted: none in a package, owned by + # a partner or in the sublocation. + self.assertTrue(group.stock_quant_ids) + self.assertFalse( + group.stock_quant_ids.filtered( + lambda q: q.package_id or q.owner_id or q.location_id != self.location + ) + ) + self.assertFalse( + self.Quant.search( + ["|", ("current_inventory_id", "=", group.id), ("to_do", "=", True)] + ) + ) + self.assertFalse( + self.Quant.search( + [ + ("location_id", "=", self.location.id), + ("inventory_quantity_set", "=", True), + ] + ) + ) + + def test_other_adjustments_take_their_usual_quants(self): + group = self.Inventory.create( + { + "name": "Back office count", + "location_ids": [Command.set(self.location.ids)], + "exclude_sublocation": True, + "product_selection": "manual", + "product_ids": [Command.set(self.product.ids)], + } + ) + quants = group._get_quants(self.location) + self.assertTrue(quants.filtered("package_id")) + self.assertTrue(quants.filtered("owner_id")) + + def test_count_not_applied_is_not_done(self): + # Should Odoo hand back a wizard instead of applying the count, the + # count fails: no adjustment is recorded as done. + wizard = { + "type": "ir.actions.act_window", + "res_model": "stock.inventory.conflict", + } + Quant = type(self.env["stock.quant"]) + with ( + patch.object(Quant, "action_apply_inventory", return_value=wizard), + self.assertRaises(UserError), + ): + self.Inventory.action_barcode_apply_count( + self.location.id, [{"product_id": self.product.id, "counted_qty": 3}] + ) + self.assertFalse( + self.Inventory.search( + [("location_ids", "=", self.location.id), ("state", "=", "done")] + ) + ) + + def test_stock_changed_while_counting(self): + data = self.Inventory.action_barcode_location_lines(self.location.id) + shown = {(line["product_id"], line["lot_name"]): line for line in data["lines"]} + # Goods come in after the screen showed the stock. + self.Quant._update_available_quantity(self.product, self.location, 5) + lines = [ + { + "product_id": self.product.id, + "counted_qty": 8, + "theoretical_qty": shown[self.product.id, False]["theoretical_qty"], + }, + { + "product_id": self.lot_product.id, + "lot_name": "LOT-COUNT-1", + "counted_qty": 5, + "theoretical_qty": 5, + }, + # A lot not created yet held nothing, as shown. + { + "product_id": self.lot_product.id, + "lot_name": "LOT-BRAND-NEW", + "counted_qty": 2, + "theoretical_qty": 0, + }, + ] + result = self.Inventory.action_barcode_apply_count(self.location.id, lines) + self.assertFalse(result["done"]) + self.assertEqual( + result["changed"], + [ + { + "product_id": self.product.id, + "product_name": self.product.display_name, + "lot_name": "", + "shown": 10.0, + "now": 15.0, + "uom": self.product.uom_id.name, + } + ], + ) + # Nothing applied, nothing created. + self.assertEqual(self._qty(self.product), 15) + self.assertFalse( + self.Inventory.search([("location_ids", "=", self.location.id)]) + ) + self.assertFalse(self.env["stock.lot"].search([("name", "=", "LOT-BRAND-NEW")])) + # The operator applies it all the same. + result = self.Inventory.action_barcode_apply_count( + self.location.id, lines, force=True + ) + self.assertTrue(result["done"]) + self.assertEqual(self._qty(self.product), 8) + + def test_stock_as_shown_is_applied(self): + self.Quant._update_available_quantity(self.new_product, self.location, -3) + result = self.Inventory.action_barcode_apply_count( + self.location.id, + [ + { + "product_id": self.new_product.id, + "counted_qty": 0, + "theoretical_qty": -3, + } + ], + ) + self.assertTrue(result["done"]) + self.assertEqual(self._qty(self.new_product), 0) + # A product gone since is left to the checks, which refuse it. + with self.assertRaises(UserError): + self.Inventory.action_barcode_apply_count( + self.location.id, + [{"product_id": 0, "counted_qty": 1, "theoretical_qty": 0}], + ) + + def test_lot_names_suggested(self): + other = self.env["res.company"].create({"name": "Barcode Lots Co"}) + self.env["stock.lot"].create( + [ + { + "name": "LOT-ELSEWHERE", + "product_id": self.lot_product.id, + "company_id": other.id, + }, + { + "name": "LOT-SHARED", + "product_id": self.lot_product.id, + "company_id": False, + }, + ] + ) + names = self.Inventory.action_barcode_lot_names( + self.location.id, self.lot_product.id + ) + # The lots a count here can use, as _barcode_resolve_lot finds them. + self.assertIn("LOT-COUNT-1", names) + self.assertIn("LOT-SHARED", names) + self.assertNotIn("LOT-ELSEWHERE", names) + self.assertEqual( + self.Inventory.action_barcode_lot_names(self.location.id, 0), [] + ) + # Looked up as the operator types, a few at a time. + lot_names = self.Inventory.action_barcode_lot_names + self.assertEqual( + lot_names(self.location.id, self.lot_product.id, "shared"), ["LOT-SHARED"] + ) + self.assertEqual( + len(lot_names(self.location.id, self.lot_product.id, "", limit=1)), 1 + ) + + def test_apply_count_matching_the_stock(self): + result = self.Inventory.action_barcode_apply_count( + self.location.id, [{"product_id": self.product.id, "counted_qty": 10}] + ) + self.assertEqual(self._qty(self.product), 10) + # The count is recorded all the same, as the back office does. + self.assertEqual(result["move_count"], 1) + + def test_apply_count_over_split_stock(self): + self.Quant.sudo().create( + { + "product_id": self.product.id, + "location_id": self.location.id, + "quantity": 2, + } + ) + self.assertEqual(self._qty(self.product), 12) + self.Inventory.action_barcode_apply_count( + self.location.id, [{"product_id": self.product.id, "counted_qty": 5}] + ) + self.assertEqual(self._qty(self.product), 5) + + def test_apply_count_in_a_shared_location(self): + shared = self.env["stock.location"].create( + { + "name": "Barcode Count Shared", + "usage": "internal", + "location_id": self.env.ref("stock.stock_location_locations").id, + "company_id": False, + } + ) + result = self.Inventory.action_barcode_apply_count( + shared.id, + [ + { + "product_id": self.lot_product.id, + "lot_name": "LOT-SHARED", + "counted_qty": 2, + } + ], + ) + group = self.Inventory.browse(result["group_id"]) + self.assertEqual(group.company_id, self.env.company) + lot = group.stock_move_ids.lot_id + self.assertEqual(lot.name, "LOT-SHARED") + self.assertEqual(lot.company_id, self.env.company) + self.assertEqual(self._qty(self.lot_product, lot, location=shared), 2) diff --git a/barcode_inventory/tests/test_js.py b/barcode_inventory/tests/test_js.py new file mode 100644 index 0000000..9fa8b7c --- /dev/null +++ b/barcode_inventory/tests/test_js.py @@ -0,0 +1,23 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestBarcodeInventoryJs(HttpCase): + """Run the module's JS unit tests (``static/tests``) in the browser. + + A Chrome/Chromium binary is required: without one the run is skipped, not + passed. + """ + + def test_js(self): + self.browser_js( + "/web/tests?headless&loglevel=2&preset=desktop&filter=%22BarcodeInventory%22", + "", + "", + login="admin", + success_signal="[HOOT] Test suite succeeded", + error_checker=lambda message: "[HOOT]" not in message, + ) diff --git a/oca_dependencies.txt b/oca_dependencies.txt index 7d1527b..a08d31a 100644 --- a/oca_dependencies.txt +++ b/oca_dependencies.txt @@ -1,2 +1,3 @@ manufacture +stock-logistics-warehouse stock-logistics-workflow