diff --git a/barcode_gs1_stock/README.rst b/barcode_gs1_stock/README.rst new file mode 100644 index 0000000..a3848f6 --- /dev/null +++ b/barcode_gs1_stock/README.rst @@ -0,0 +1,169 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================= +Barcode GS1 Stock +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:d8079773a4f26fd724ecda9cb0c83d834ae1a0668f5ce94ab8c25bde458aa955 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_gs1_stock + :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_gs1_stock + :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| + +This module is the bridge between **GS1 barcodes** and the **warehouse +app**: it makes the measure a GS1 label carries the quantity picked, +expressed in the unit the product is stocked in. + +A GS1 label often states both, and they disagree: a box of cured meat +carries "2 pieces" (AI 30) and "2.497 kg" (AI 310n). Which of the two is +the quantity depends on how the product is stocked, something only the +warehouse knows — kilograms make it the weight, units make it the count. + +It installs itself as soon as both ``barcode_gs1`` and ``barcode_stock`` +are installed, and neither needs the other without it. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Nothing to configure for the module itself. The precision a measure is +kept at is Odoo's, though, and its defaults are coarser than most GS1 +labels. + +A measure becomes a quantity rounded twice, as anywhere in stock: to the +*Rounding Precision* of the product's unit, and to the decimals of the +*Product Unit of Measure* accuracy. Both default to two decimals, so the +2.497 kg a label states is stored as 2.50 kg, and the app shows 2.50 so +that the quantity on screen is the one that will be stored. + +To keep the grams a label states, for products stocked in kilograms: + +- set the *Rounding Precision* of *kg* to 0.001, under *Inventory → + Configuration → Units of Measures → UoM Categories → Weight* (the + *Units of Measure* option must be enabled in the Inventory settings); +- set the digits of *Product Unit of Measure* to 3, under *Settings → + Technical → Database Structure → Decimal Accuracy* (developer mode). + +The accuracy applies to every quantity in the database, not only to +scans. + +Usage +===== + +Installing ``barcode_gs1`` next to ``barcode_stock`` brings this module +in, and every screen that applies a scanned quantity uses it. + +When a scanned GS1 barcode carries a measure, the unit that measure is +in comes from the rule that read it (*Unit of Measure* on the barcode +rule — AI 310n is kilograms, 320n pounds, 311n metres). It is then +compared with the unit the product is stocked in: + +- **same kind of unit** — the measure is the quantity, converted into + the product's own unit: 2.497 kg on the label is 2497 for a product in + grams; +- **a different kind** — a weight for a product counted in units, say — + the measure says nothing about how many to pick, so the piece count on + the label (AI 30/37) is the quantity, and a single unit when the label + states none. + +When a label states several measures (a net weight and a net volume, +say), the first one in the product's kind of unit is taken. The gross +weight and the other measures of the pack itself (AI 330n–349n) never +are. + +The quantity is rounded the way it will be stored, so what the screen +shows is what ends up in stock. With Odoo's defaults that is two +decimals: 2.497 kg is 2.50 for a product in kilograms (see Configuration +to keep the grams). A measure that rounds to nothing (4 g, for a product +in kilograms kept to two decimals) is not taken: the scan reads as one +without a measure. + +When the GTIN is a packaging barcode (a carton of twelve, say), a label +without a count picks the pack's quantity, and a count on the label wins +over it. A measure in the product's own kind of unit wins over both: a +carton of cheese weighing 4.32 kg is 4.32 for a product stocked in +kilograms, not twelve. + +The units of measure are read when the app opens. Should that fail, they +are read again on the next scan that carries a measure; that one scan +reads as if it carried none. + +Without this module GS1 barcodes are still parsed, but a measure is +never the quantity: the warehouse app takes the piece count, or a single +unit (the pack's quantity for a packaging barcode). + +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 +------------ + +- `Antonio David Ruban Espinal `__ +- `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_gs1_stock/__init__.py b/barcode_gs1_stock/__init__.py new file mode 100644 index 0000000..081753d --- /dev/null +++ b/barcode_gs1_stock/__init__.py @@ -0,0 +1,2 @@ +# barcode_gs1_stock has no Python models; it teaches the warehouse app how to +# read the quantity off a GS1 label from the frontend assets. diff --git a/barcode_gs1_stock/__manifest__.py b/barcode_gs1_stock/__manifest__.py new file mode 100644 index 0000000..9ee3f63 --- /dev/null +++ b/barcode_gs1_stock/__manifest__.py @@ -0,0 +1,24 @@ +{ + "name": "Barcode GS1 Stock", + "version": "18.0.1.0.0", + "category": "Inventory/Logistics", + "summary": "Use the measure on a GS1 label as the quantity picked", + "author": "Binhex, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/barcode-interface", + "maintainers": ["szalatyzuzanna"], + "license": "AGPL-3", + "depends": [ + "barcode_gs1", + "barcode_stock", + ], + "assets": { + "web.assets_backend": [ + "barcode_gs1_stock/static/src/js/gs1_stock_quantity.esm.js", + ], + "web.assets_unit_tests": [ + "barcode_gs1_stock/static/tests/**/*", + ], + }, + "installable": True, + "auto_install": True, +} diff --git a/barcode_gs1_stock/pyproject.toml b/barcode_gs1_stock/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/barcode_gs1_stock/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/barcode_gs1_stock/readme/CONFIGURE.md b/barcode_gs1_stock/readme/CONFIGURE.md new file mode 100644 index 0000000..347026f --- /dev/null +++ b/barcode_gs1_stock/readme/CONFIGURE.md @@ -0,0 +1,19 @@ +Nothing to configure for the module itself. The precision a measure is kept at +is Odoo's, though, and its defaults are coarser than most GS1 labels. + +A measure becomes a quantity rounded twice, as anywhere in stock: to the +*Rounding Precision* of the product's unit, and to the decimals of the +*Product Unit of Measure* accuracy. Both default to two decimals, so the +2.497 kg a label states is stored as 2.50 kg, and the app shows 2.50 so that the +quantity on screen is the one that will be stored. + +To keep the grams a label states, for products stocked in kilograms: + +- set the *Rounding Precision* of *kg* to 0.001, under + *Inventory → Configuration → Units of Measures → UoM Categories → Weight* + (the *Units of Measure* option must be enabled in the Inventory settings); +- set the digits of *Product Unit of Measure* to 3, under + *Settings → Technical → Database Structure → Decimal Accuracy* (developer + mode). + +The accuracy applies to every quantity in the database, not only to scans. diff --git a/barcode_gs1_stock/readme/CONTRIBUTORS.md b/barcode_gs1_stock/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..7a07748 --- /dev/null +++ b/barcode_gs1_stock/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +* [Antonio David Ruban Espinal](https://github.com/antoniodavid) +* [Zuzanna Elzbieta Szalaty Szalaty](https://github.com/szalatyzuzanna) diff --git a/barcode_gs1_stock/readme/DESCRIPTION.md b/barcode_gs1_stock/readme/DESCRIPTION.md new file mode 100644 index 0000000..87a06a3 --- /dev/null +++ b/barcode_gs1_stock/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +This module is the bridge between **GS1 barcodes** and the **warehouse app**: it +makes the measure a GS1 label carries the quantity picked, expressed in the unit +the product is stocked in. + +A GS1 label often states both, and they disagree: a box of cured meat carries +"2 pieces" (AI 30) and "2.497 kg" (AI 310n). Which of the two is the quantity +depends on how the product is stocked, something only the warehouse knows — +kilograms make it the weight, units make it the count. + +It installs itself as soon as both ``barcode_gs1`` and ``barcode_stock`` are +installed, and neither needs the other without it. diff --git a/barcode_gs1_stock/readme/USAGE.md b/barcode_gs1_stock/readme/USAGE.md new file mode 100644 index 0000000..6351875 --- /dev/null +++ b/barcode_gs1_stock/readme/USAGE.md @@ -0,0 +1,36 @@ +Installing ``barcode_gs1`` next to ``barcode_stock`` brings this module in, and +every screen that applies a scanned quantity uses it. + +When a scanned GS1 barcode carries a measure, the unit that measure is in comes +from the rule that read it (*Unit of Measure* on the barcode rule — AI 310n is +kilograms, 320n pounds, 311n metres). It is then compared with the unit the +product is stocked in: + +- **same kind of unit** — the measure is the quantity, converted into the + product's own unit: 2.497 kg on the label is 2497 for a product in grams; +- **a different kind** — a weight for a product counted in units, say — the + measure says nothing about how many to pick, so the piece count on the label + (AI 30/37) is the quantity, and a single unit when the label states none. + +When a label states several measures (a net weight and a net volume, say), the +first one in the product's kind of unit is taken. The gross weight and the other +measures of the pack itself (AI 330n–349n) never are. + +The quantity is rounded the way it will be stored, so what the screen shows is +what ends up in stock. With Odoo's defaults that is two decimals: 2.497 kg is +2.50 for a product in kilograms (see Configuration to keep the grams). A measure +that rounds to nothing (4 g, for a product in kilograms kept to two decimals) is +not taken: the scan reads as one without a measure. + +When the GTIN is a packaging barcode (a carton of twelve, say), a label without +a count picks the pack's quantity, and a count on the label wins over it. A +measure in the product's own kind of unit wins over both: a carton of cheese +weighing 4.32 kg is 4.32 for a product stocked in kilograms, not twelve. + +The units of measure are read when the app opens. Should that fail, they are read +again on the next scan that carries a measure; that one scan reads as if it +carried none. + +Without this module GS1 barcodes are still parsed, but a measure is never the +quantity: the warehouse app takes the piece count, or a single unit (the pack's +quantity for a packaging barcode). diff --git a/barcode_gs1_stock/static/description/icon.png b/barcode_gs1_stock/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/barcode_gs1_stock/static/description/icon.png differ diff --git a/barcode_gs1_stock/static/description/index.html b/barcode_gs1_stock/static/description/index.html new file mode 100644 index 0000000..e797ddc --- /dev/null +++ b/barcode_gs1_stock/static/description/index.html @@ -0,0 +1,503 @@ + + + + + +Barcode GS1 Stock + + + +
+ + + +Odoo Community Association + +
+

Barcode GS1 Stock

+ +

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

+

This module is the bridge between GS1 barcodes and the warehouse +app: it makes the measure a GS1 label carries the quantity picked, +expressed in the unit the product is stocked in.

+

A GS1 label often states both, and they disagree: a box of cured meat +carries “2 pieces” (AI 30) and “2.497 kg” (AI 310n). Which of the two is +the quantity depends on how the product is stocked, something only the +warehouse knows — kilograms make it the weight, units make it the count.

+

It installs itself as soon as both barcode_gs1 and barcode_stock +are installed, and neither needs the other without it.

+

Table of contents

+ +
+

Configuration

+

Nothing to configure for the module itself. The precision a measure is +kept at is Odoo’s, though, and its defaults are coarser than most GS1 +labels.

+

A measure becomes a quantity rounded twice, as anywhere in stock: to the +Rounding Precision of the product’s unit, and to the decimals of the +Product Unit of Measure accuracy. Both default to two decimals, so the +2.497 kg a label states is stored as 2.50 kg, and the app shows 2.50 so +that the quantity on screen is the one that will be stored.

+

To keep the grams a label states, for products stocked in kilograms:

+
    +
  • set the Rounding Precision of kg to 0.001, under Inventory → +Configuration → Units of Measures → UoM Categories → Weight (the +Units of Measure option must be enabled in the Inventory settings);
  • +
  • set the digits of Product Unit of Measure to 3, under Settings → +Technical → Database Structure → Decimal Accuracy (developer mode).
  • +
+

The accuracy applies to every quantity in the database, not only to +scans.

+
+
+

Usage

+

Installing barcode_gs1 next to barcode_stock brings this module +in, and every screen that applies a scanned quantity uses it.

+

When a scanned GS1 barcode carries a measure, the unit that measure is +in comes from the rule that read it (Unit of Measure on the barcode +rule — AI 310n is kilograms, 320n pounds, 311n metres). It is then +compared with the unit the product is stocked in:

+
    +
  • same kind of unit — the measure is the quantity, converted into +the product’s own unit: 2.497 kg on the label is 2497 for a product in +grams;
  • +
  • a different kind — a weight for a product counted in units, say — +the measure says nothing about how many to pick, so the piece count on +the label (AI 30/37) is the quantity, and a single unit when the label +states none.
  • +
+

When a label states several measures (a net weight and a net volume, +say), the first one in the product’s kind of unit is taken. The gross +weight and the other measures of the pack itself (AI 330n–349n) never +are.

+

The quantity is rounded the way it will be stored, so what the screen +shows is what ends up in stock. With Odoo’s defaults that is two +decimals: 2.497 kg is 2.50 for a product in kilograms (see Configuration +to keep the grams). A measure that rounds to nothing (4 g, for a product +in kilograms kept to two decimals) is not taken: the scan reads as one +without a measure.

+

When the GTIN is a packaging barcode (a carton of twelve, say), a label +without a count picks the pack’s quantity, and a count on the label wins +over it. A measure in the product’s own kind of unit wins over both: a +carton of cheese weighing 4.32 kg is 4.32 for a product stocked in +kilograms, not twelve.

+

The units of measure are read when the app opens. Should that fail, they +are read again on the next scan that carries a measure; that one scan +reads as if it carried none.

+

Without this module GS1 barcodes are still parsed, but a measure is +never the quantity: the warehouse app takes the piece count, or a single +unit (the pack’s quantity for a packaging barcode).

+
+
+

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_gs1_stock/static/src/js/gs1_stock_quantity.esm.js b/barcode_gs1_stock/static/src/js/gs1_stock_quantity.esm.js new file mode 100644 index 0000000..61967ce --- /dev/null +++ b/barcode_gs1_stock/static/src/js/gs1_stock_quantity.esm.js @@ -0,0 +1,183 @@ +import {roundDecimals, roundPrecision} from "@web/core/utils/numbers"; +import {BarcodeScannerState} from "@barcode_stock/js/services/barcode_scanner_state.esm"; +import {barcodeStartupTasks} from "@barcode_scanner/js/registries.esm"; +import {patch} from "@web/core/utils/patch"; + +// Logistic measures (AI 330n-349n: the gross weight, dimensions and volume of +// the logistic unit) describe the pack, never the goods picked. +const LOGISTIC_MEASURE_AI = /^3[34]/; + +/** + * The measures of the goods a scan carries, in the order the label states them: + * every one `barcode_gs1` lists in `measures`, or the single `weight`/`weightUom` + * pair a scan without that list carries. + */ +function goodsMeasures(scan) { + if (Array.isArray(scan?.measures)) { + return scan.measures.filter( + (measure) => !LOGISTIC_MEASURE_AI.test(measure.ai || "") + ); + } + if (scan?.weight === null || scan?.weight === undefined) { + return []; + } + return [{value: scan.weight, uom: scan.weightUom}]; +} + +/** + * Read the quantity of a GS1 scan the way the warehouse means it. + * + * A GS1 label states both a piece count (AI 30/37) and, for goods sold by + * weight, a measure (AI 310n and friends) — a box of cured meat carries "2 + * pieces" and "2.497 kg". Neither `barcode_gs1`, which only decodes, nor + * `barcode_stock`, which knows nothing of GS1, can decide which one is the + * quantity picked: that depends on the unit the product is stocked in, so it is + * decided here. + */ +patch(BarcodeScannerState.prototype, { + /** + * Fetch every unit of measure once, with the number of decimals a quantity + * is stored with. There are a handful of units, they are needed to compare + * a measure with a product, and the scan path itself is synchronous — so + * they are warmed up when the app starts. + */ + async loadUoms() { + if (this.uomsById && Object.keys(this.uomsById).length) { + return this.uomsById; + } + const [uoms, digits] = await Promise.all([ + this.orm.searchRead( + "uom.uom", + [], + ["name", "category_id", "factor", "rounding"] + ), + // The decimals of "Product Unit of Measure", as a quantity field + // that is stored with them states them. Not from precision_get over + // RPC: once `account` is installed it overrides that method without + // @api.model, and the call then takes the precision's name for ids. + this.orm + .call("stock.move.line", "fields_get", [["quantity"]], { + attributes: ["digits"], + }) + .then((fields) => fields?.quantity?.digits?.[1]) + .catch(() => null), + ]); + this.quantityDigits = Number.isInteger(digits) ? digits : null; + this.uomsById = Object.fromEntries(uoms.map((uom) => [uom.id, uom])); + return this.uomsById; + }, + + /** + * Load the units again, in the background, when loading them at startup + * failed (a dropped connection, say) and a scan carries a measure. That scan + * keeps the base reading; the next ones read their measure. + */ + retryLoadUoms(scan) { + if (this.uomsById || this.uomsRetry || !goodsMeasures(scan).length) { + return; + } + this.uomsRetry = this.loadUoms() + .catch(() => null) + .finally(() => { + this.uomsRetry = null; + }); + }, + + /** + * The first measure of the goods whose unit is the kind the product is + * stocked in, with that unit: a net volume for a product in litres, even when + * the label states a net weight first. + */ + matchingMeasure(scan, productUom) { + for (const measure of goodsMeasures(scan)) { + const value = parseFloat(measure.value); + const uom = this.uomsById[measure.uom?.id]; + if ( + Number.isFinite(value) && + uom?.category_id?.[0] === productUom.category_id?.[0] + ) { + return {value, uom}; + } + } + return null; + }, + + /** + * The measure a GS1 label carries, as a quantity of the product: converted + * into the unit the product is stocked in, and rounded the way the server + * will store it. Null when the label has no measure of the kind the product + * is stocked in (a weight for a product counted in units), when the units + * could not be read, or when the measure rounds to nothing: a quantity of + * zero picks nothing, so the scan reads as one without a measure. + */ + measureQuantity(scan, productUomId) { + if (!this.uomsById) { + this.retryLoadUoms(scan); + return null; + } + const productUom = this.uomsById[productUomId]; + const measure = productUom && this.matchingMeasure(scan, productUom); + if (!measure) { + return null; + } + // Odoo's factor is how many of a unit make one unit of its category's + // reference, so converting is a ratio of the two. + const quantity = this.roundAsStored( + (measure.value / (measure.uom.factor || 1)) * (productUom.factor || 1), + productUom + ); + return quantity > 0 ? quantity : null; + }, + + /** + * A quantity in `uom` as the server will store it. What lands in stock is + * rounded to the unit's rounding and to the decimals of "Product Unit of + * Measure": with the defaults (0.01 kg, two decimals) the 2.497 kg a label + * states is stored as 2.50. Rounding here too shows the quantity that will + * be stored, not one that will change once saved. + */ + roundAsStored(quantity, uom) { + let rounded = + uom.rounding > 0 ? roundPrecision(quantity, uom.rounding) : quantity; + if (Number.isInteger(this.quantityDigits)) { + rounded = roundDecimals(rounded, this.quantityDigits); + } + return rounded; + }, + + /** + * @override + * The measure becomes the quantity when its unit is the kind the product is + * stocked in. Otherwise the base reading stands: the piece count on the + * label, or a single unit when it states none — a weight must never turn + * into a number of units. + */ + scannedQuantity(scan, productUomId) { + const measured = this.measureQuantity(scan, productUomId); + return measured === null ? super.scannedQuantity(...arguments) : measured; + }, + + /** + * @override + * A packaging barcode stands for a whole pack, and the base takes the pack's + * quantity whenever the label states no count. A measure is more precise + * than that: a carton of cheese weighing 4.32 kg is 4.32 for a product + * stocked in kilograms, not the pack's nominal quantity. + */ + applyScanResult(scan) { + const result = super.applyScanResult(...arguments); + const productId = result.candidates?.[0]?.product_id?.[0]; + const measured = this.measureQuantity( + scan, + this.productsById?.[productId]?.uom_id?.[0] + ); + if (measured !== null) { + result.quantity = measured; + } + return result; + }, +}); + +barcodeStartupTasks.add("gs1_stock_uoms", (env) => + env.services.barcodeScannerState.loadUoms() +); diff --git a/barcode_gs1_stock/static/tests/gs1_stock_quantity.test.js b/barcode_gs1_stock/static/tests/gs1_stock_quantity.test.js new file mode 100644 index 0000000..8cb0b9f --- /dev/null +++ b/barcode_gs1_stock/static/tests/gs1_stock_quantity.test.js @@ -0,0 +1,340 @@ +import "@barcode_gs1_stock/js/gs1_stock_quantity.esm"; +import {afterEach, describe, expect, test} from "@odoo/hoot"; +import { + compileGs1Nomenclature, + setGs1Nomenclature, +} from "@barcode_gs1/js/gs1_nomenclature.esm"; +import {BarcodeScannerState} from "@barcode_stock/js/services/barcode_scanner_state.esm"; +import {parseGs1} from "@barcode_gs1/js/gs1_parser.esm"; + +// Units of measure as a stock database ships them: `factor` is how many of the +// unit make one unit of its category's reference (a kilogram is 1000 grams), +// and `rounding` is what a quantity in that unit is rounded to. +const UOMS = [ + {id: 1, name: "Units", category_id: [1, "Unit"], factor: 1.0, rounding: 0.01}, + {id: 13, name: "kg", category_id: [3, "Weight"], factor: 1.0, rounding: 0.01}, + {id: 14, name: "g", category_id: [3, "Weight"], factor: 1000.0, rounding: 0.01}, + {id: 16, name: "lb", category_id: [3, "Weight"], factor: 2.20462, rounding: 0.01}, + {id: 11, name: "L", category_id: [5, "Volume"], factor: 1.0, rounding: 0.01}, + { + id: 6, + name: "m", + category_id: [4, "Length / Distance"], + factor: 1.0, + rounding: 0.01, + }, +]; + +/** + * A scanner state with the units loaded. By default, the way Odoo ships: units + * rounded to 0.01 and quantities stored with two decimals. `rounding` overrides + * a unit's rounding by id, `digits` the decimals of "Product Unit of Measure". + */ +function stateWithUoms({rounding = {}, digits = 2} = {}) { + const state = new BarcodeScannerState({}); + state.uomsById = Object.fromEntries( + UOMS.map((uom) => [ + uom.id, + {...uom, rounding: rounding[uom.id] ?? uom.rounding}, + ]) + ); + state.quantityDigits = digits; + return state; +} + +// The configuration that reads grams: kilograms rounded to 0.001 and quantities +// stored with three decimals. +function stateReadingGrams() { + return stateWithUoms({rounding: {13: 0.001}, digits: 3}); +} + +// What barcode_gs1 hands over for a box of cured meat: two pieces, 2.497 kg. +const TWO_PIECES_OF_2497_G = { + qty: 2, + quantity: 2, + count: 2, + weight: 2.497, + weightUom: {id: 13, name: "kg"}, +}; + +describe("Gs1StockQuantity", () => { + test("a product stocked by weight takes the weight, in its own unit", () => { + const state = stateReadingGrams(); + expect(state.scannedQuantity(TWO_PIECES_OF_2497_G, 13)).toBe(2.497); + // The label weighs in kilograms, the product is stocked in grams. + expect(state.scannedQuantity(TWO_PIECES_OF_2497_G, 14)).toBe(2497); + }); + + test("the weight is rounded the way it will be stored", () => { + // Odoo's defaults keep two decimals of a kilogram: 2.497 kg is stored as + // 2.50, so that is the quantity shown. + expect(stateWithUoms().scannedQuantity(TWO_PIECES_OF_2497_G, 13)).toBe(2.5); + // Both settings count: a finer unit with two decimals still stores 2.50, + // and so do three decimals with a unit rounded to 0.01. + const finerUnit = stateWithUoms({rounding: {13: 0.001}}); + expect(finerUnit.scannedQuantity(TWO_PIECES_OF_2497_G, 13)).toBe(2.5); + const moreDecimals = stateWithUoms({digits: 3}); + expect(moreDecimals.scannedQuantity(TWO_PIECES_OF_2497_G, 13)).toBe(2.5); + // When the decimals could not be read, the unit's rounding still applies. + const noDigits = stateWithUoms({rounding: {13: 0.001}, digits: null}); + expect(noDigits.scannedQuantity(TWO_PIECES_OF_2497_G, 13)).toBe(2.497); + }); + + test("a product counted in units takes the piece count", () => { + expect(stateWithUoms().scannedQuantity(TWO_PIECES_OF_2497_G, 1)).toBe(2); + }); + + test("a weight with no count is a single unit for a product in units", () => { + // A cheese wheel label: 4.324 kg net and no count at all. Adding "4.324 + // units" would be wrong; one box was scanned. + const wheel = {qty: null, weight: 4.324, weightUom: {id: 13, name: "kg"}}; + expect(stateReadingGrams().scannedQuantity(wheel, 13)).toBe(4.324); + expect(stateWithUoms().scannedQuantity(wheel, 13)).toBe(4.32); + expect(stateWithUoms().scannedQuantity(wheel, 1)).toBe(1); + }); + + test("a measure in another unit of the same category is converted", () => { + const pounds = {qty: null, weight: 5, weightUom: {id: 16, name: "lb"}}; + expect(stateReadingGrams().scannedQuantity(pounds, 13)).toBe(2.268); + expect(stateWithUoms().scannedQuantity(pounds, 13)).toBe(2.27); + }); + + test("a measure of the wrong kind never becomes the quantity", () => { + // A length says nothing about how much of a product to pick. + const length = {qty: 3, count: 3, weight: 2.5, weightUom: {id: 6, name: "m"}}; + const state = stateWithUoms(); + expect(state.scannedQuantity(length, 6)).toBe(2.5); + expect(state.scannedQuantity(length, 13)).toBe(3); + }); + + test("of several measures, the one in the product's kind of unit is taken", () => { + // A net weight and a net volume, as barcode_gs1 lists them. + const kilograms = {ai: "3103", value: 2.497, uom: {id: 13, name: "kg"}}; + const litres = {ai: "3152", value: 2.5, uom: {id: 11, name: "L"}}; + const both = { + qty: null, + weight: 2.497, + weightUom: {id: 13, name: "kg"}, + measures: [kilograms, litres], + }; + const state = stateReadingGrams(); + expect(state.scannedQuantity(both, 11)).toBe(2.5); + expect(state.scannedQuantity(both, 13)).toBe(2.497); + expect(state.scannedQuantity(both, 1)).toBe(1); + }); + + test("the gross weight of the pack is never the quantity", () => { + // Even with a unit configured on its rule, AI 330n weighs the pack. + const gross = {ai: "3303", value: 2.7, uom: {id: 13, name: "kg"}}; + const net = {ai: "3103", value: 2.497, uom: {id: 13, name: "kg"}}; + const state = stateReadingGrams(); + expect(state.scannedQuantity({qty: null, measures: [gross, net]}, 13)).toBe( + 2.497 + ); + expect(state.scannedQuantity({qty: null, measures: [gross]}, 13)).toBe(1); + }); + + test("a measure that rounds to nothing reads as a scan without one", () => { + // 4 g of a product stocked in kilograms rounded to 0.01: a quantity of + // zero would pick nothing, so the base reading stands. + const pinch = {weight: 0.004, weightUom: {id: 13, name: "kg"}}; + const state = stateWithUoms(); + expect(state.scannedQuantity({...pinch, qty: null}, 13)).toBe(1); + expect(state.scannedQuantity({...pinch, qty: 3, count: 3}, 13)).toBe(3); + expect(state.scannedQuantity({...pinch, weight: 0, qty: null}, 13)).toBe(1); + // The same 4 g is read when kilograms keep three decimals. + expect(stateReadingGrams().scannedQuantity(pinch, 13)).toBe(0.004); + }); + + test("without a measure, the base reading stands", () => { + const state = stateWithUoms(); + expect(state.scannedQuantity({qty: 7, count: 7}, 13)).toBe(7); + expect(state.scannedQuantity({}, 13)).toBe(1); + expect(state.scannedQuantity(null, 13)).toBe(1); + // A unit we could not read leaves the stated quantity alone. + expect(state.scannedQuantity(TWO_PIECES_OF_2497_G, 999)).toBe(2); + }); +}); + +describe("Gs1StockQuantity loading", () => { + function fakeOrm({digits = 3, fail = false} = {}) { + const calls = []; + return { + calls, + async searchRead(model, domain, fields) { + calls.push([model, fields]); + return UOMS; + }, + async call(model, method, args, kwargs) { + calls.push([model, method, args, kwargs]); + if (fail) { + throw new Error("No access"); + } + return {quantity: {digits: [16, digits]}}; + }, + }; + } + + test("the units and the stored decimals are read once", async () => { + const orm = fakeOrm(); + const state = new BarcodeScannerState(orm); + await state.loadUoms(); + expect(state.uomsById[13].rounding).toBe(0.01); + expect(state.quantityDigits).toBe(3); + expect(orm.calls).toEqual([ + ["uom.uom", ["name", "category_id", "factor", "rounding"]], + ["stock.move.line", "fields_get", [["quantity"]], {attributes: ["digits"]}], + ]); + await state.loadUoms(); + expect(orm.calls.length).toBe(2); + }); + + test("the units still load when the decimals cannot be read", async () => { + const state = new BarcodeScannerState(fakeOrm({fail: true})); + await state.loadUoms(); + expect(state.uomsById[13].name).toBe("kg"); + expect(state.quantityDigits).toBe(null); + }); + + test("units that failed to load are loaded again by the next measure", async () => { + // The connection drops when the app opens, then comes back twice later. + const outcomes = ["offline", "offline", "online"]; + const attempts = []; + const orm = { + async searchRead() { + const outcome = outcomes[attempts.length]; + attempts.push(outcome); + if (outcome === "offline") { + throw new Error("offline"); + } + return UOMS; + }, + async call() { + return 2; + }, + }; + const state = new BarcodeScannerState(orm); + await state.loadUoms().catch(() => null); + const weighed = {qty: null, weight: 2.497, weightUom: {id: 13, name: "kg"}}; + // A scan without a measure does not need the units. + expect(state.scannedQuantity({qty: 2, count: 2}, 13)).toBe(2); + expect(attempts.length).toBe(1); + // One with a measure keeps the base reading and loads them again, once + // however often it is read meanwhile. + expect(state.scannedQuantity(weighed, 13)).toBe(1); + expect(state.scannedQuantity(weighed, 13)).toBe(1); + await state.uomsRetry; + expect(attempts.length).toBe(2); + // Still offline: the next measure tries again, and that one succeeds. + expect(state.scannedQuantity(weighed, 13)).toBe(1); + await state.uomsRetry; + expect(attempts).toEqual(["offline", "offline", "online"]); + expect(state.scannedQuantity(weighed, 13)).toBe(2.5); + }); +}); + +describe("Gs1StockQuantity packaging", () => { + // The carton's own GTIN-14 (packaging indicator 1) holds twelve cheeses. + const CARTON = "19501101020914"; + + afterEach(() => setGs1Nomenclature(null)); + + // The rules of Odoo's GS1 nomenclature these labels need, with the unit each + // measure is expressed in. + function loadNomenclature() { + setGs1Nomenclature( + compileGs1Nomenclature({id: 1, name: "Default GS1 Nomenclature"}, [ + { + name: "GTIN", + sequence: 2, + pattern: "(01)(\\d{14})", + type: "product", + gs1_content_type: "identifier", + }, + { + name: "Variable count of items", + sequence: 20, + pattern: "(30)(\\d{0,8})", + type: "quantity", + gs1_content_type: "measure", + gs1_decimal_usage: false, + }, + { + name: "Net weight, kilograms", + sequence: 21, + pattern: "(310[0-5])(\\d{6})", + type: "quantity", + gs1_content_type: "measure", + gs1_decimal_usage: true, + associated_uom_id: [13, "kg"], + }, + { + name: "Net volume, litres", + sequence: 25, + pattern: "(315[0-5])(\\d{6})", + type: "quantity", + gs1_content_type: "measure", + gs1_decimal_usage: true, + associated_uom_id: [11, "L"], + }, + ]) + ); + } + + // A picking with one move of cheese, whose carton is a packaging of 12. + function pickingState(productUomId) { + const state = stateWithUoms(); + state.productsById = { + 7: { + id: 7, + display_name: "Cheese", + barcode: "9501101020917", + uom_id: [productUomId, "unit"], + tracking: "none", + }, + }; + state.moves = [{id: 70, product_id: [7, "Cheese"]}]; + state.packagings = [ + {id: 3, product_id: [7, "Cheese"], barcode: CARTON, qty: 12}, + ]; + state.buildIndexes(); + return state; + } + + function scan(state, label) { + return state.applyScanResult({barcode: label, ...parseGs1(label)}); + } + + test("a carton label with no count is the pack's quantity", () => { + loadNomenclature(); + // The weight is decoded, but a product counted in units ignores it. + const result = scan(pickingState(1), `(01)${CARTON}(3103)004324`); + expect(result.candidates.length).toBe(1); + expect(result.quantity).toBe(12); + expect(scan(pickingState(1), `(01)${CARTON}`).quantity).toBe(12); + }); + + test("a count on the carton label wins over the pack's quantity", () => { + loadNomenclature(); + expect(scan(pickingState(1), `(01)${CARTON}(30)10`).quantity).toBe(10); + }); + + test("a carton weighed for a product stocked by weight is the weight", () => { + loadNomenclature(); + // 4.324 kg, stored with Odoo's two decimals: not the pack's 12. + const result = scan(pickingState(13), `(01)${CARTON}(3103)004324`); + expect(result.quantity).toBe(4.32); + }); + + test("a carton labelled by weight and volume fits a product in litres", () => { + loadNomenclature(); + const label = `(01)${CARTON}(3103)004324(3152)000450`; + expect(scan(pickingState(11), label).quantity).toBe(4.5); + expect(scan(pickingState(13), label).quantity).toBe(4.32); + }); + + test("a carton weight that rounds to nothing leaves the pack's quantity", () => { + loadNomenclature(); + expect(scan(pickingState(13), `(01)${CARTON}(3103)000004`).quantity).toBe(12); + }); +}); diff --git a/barcode_gs1_stock/tests/__init__.py b/barcode_gs1_stock/tests/__init__.py new file mode 100644 index 0000000..e53e694 --- /dev/null +++ b/barcode_gs1_stock/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_js +from . import test_user_access diff --git a/barcode_gs1_stock/tests/test_js.py b/barcode_gs1_stock/tests/test_js.py new file mode 100644 index 0000000..50daf06 --- /dev/null +++ b/barcode_gs1_stock/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 TestBarcodeGs1StockJs(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=Gs1StockQuantity", + "", + "", + login="admin", + success_signal="[HOOT] Test suite succeeded", + error_checker=lambda message: "[HOOT]" not in message, + ) diff --git a/barcode_gs1_stock/tests/test_user_access.py b/barcode_gs1_stock/tests/test_user_access.py new file mode 100644 index 0000000..4636e21 --- /dev/null +++ b/barcode_gs1_stock/tests/test_user_access.py @@ -0,0 +1,47 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import HttpCase, new_test_user, tagged + + +@tagged("post_install", "-at_install") +class TestBarcodeGs1StockUserAccess(HttpCase): + """The app reads the units of measure as whoever opens it. + + The Scanner menu is open to every internal user, so a plain employee must be + able to make the same calls ``gs1_stock_quantity.esm.js`` makes when the app + starts, through the same RPC route. + """ + + def call_kw(self, model, method, args, kwargs=None): + return self.make_jsonrpc_request( + f"/web/dataset/call_kw/{model}/{method}", + {"model": model, "method": method, "args": args, "kwargs": kwargs or {}}, + ) + + def test_employee_reads_the_units_and_the_stored_decimals(self): + new_test_user( + self.env, login="barcode_gs1_stock_employee", groups="base.group_user" + ) + self.authenticate("barcode_gs1_stock_employee", "barcode_gs1_stock_employee") + uoms = self.call_kw( + "uom.uom", + "search_read", + [[]], + {"fields": ["name", "category_id", "factor", "rounding"]}, + ) + kilogram = self.env.ref("uom.product_uom_kgm") + self.assertIn( + kilogram.rounding, + [uom["rounding"] for uom in uoms if uom["id"] == kilogram.id], + ) + # The decimals come from a quantity field stored with them, not from + # decimal.precision.precision_get: `account` overrides that method + # without @api.model, which breaks it over RPC. + fields = self.call_kw( + "stock.move.line", "fields_get", [["quantity"]], {"attributes": ["digits"]} + ) + self.assertEqual( + fields["quantity"]["digits"][1], + self.env["decimal.precision"].precision_get("Product Unit of Measure"), + )