diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000000..c18d2841f0b --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +shopfloor_mobile_packing/static/src/js diff --git a/setup/shopfloor_mobile_packing/odoo/addons/shopfloor_mobile_packing b/setup/shopfloor_mobile_packing/odoo/addons/shopfloor_mobile_packing new file mode 120000 index 00000000000..0069ba2870a --- /dev/null +++ b/setup/shopfloor_mobile_packing/odoo/addons/shopfloor_mobile_packing @@ -0,0 +1 @@ +../../../../shopfloor_mobile_packing \ No newline at end of file diff --git a/setup/shopfloor_mobile_packing/setup.py b/setup/shopfloor_mobile_packing/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/shopfloor_mobile_packing/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/shopfloor_packing/odoo/addons/shopfloor_packing b/setup/shopfloor_packing/odoo/addons/shopfloor_packing new file mode 120000 index 00000000000..a02fb6c0aaf --- /dev/null +++ b/setup/shopfloor_packing/odoo/addons/shopfloor_packing @@ -0,0 +1 @@ +../../../../shopfloor_packing \ No newline at end of file diff --git a/setup/shopfloor_packing/setup.py b/setup/shopfloor_packing/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/shopfloor_packing/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopfloor_mobile/static/wms/src/scenario/cluster_picking.js b/shopfloor_mobile/static/wms/src/scenario/cluster_picking.js index 91ff499d2fb..d947ce366f6 100644 --- a/shopfloor_mobile/static/wms/src/scenario/cluster_picking.js +++ b/shopfloor_mobile/static/wms/src/scenario/cluster_picking.js @@ -86,6 +86,19 @@ const ClusterPicking = { +
+ + +
+
+ Destination: + {{ state.data.location_dest.name }} +
+
+
+
+
+
+
+ + Test + + + +
+
+ Package: + {{ state.data.package.name }} +
+
+
+
+ + +
+
+ Destination: + {{ state.data.location_dest.name }} +
+
+
+
+
+
- + diff --git a/shopfloor_mobile_packing/README.rst b/shopfloor_mobile_packing/README.rst new file mode 100644 index 00000000000..7f83e9e5f8b --- /dev/null +++ b/shopfloor_mobile_packing/README.rst @@ -0,0 +1,78 @@ +======================== +Shopfloor Mobile Packing +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b7902164a2332698048e39e0a5f28cb8ed1e741c6e20def9237892380b2c7333 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github + :target: https://github.com/OCA/wms/tree/16.0/shopfloor_mobile_packing + :alt: OCA/wms +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/wms-16-0/wms-16-0-shopfloor_mobile_packing + :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/wms&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Shopfloor mobile extension for packing operation into cluster picking. + +**Table of contents** + +.. contents:: + :local: + +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 +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Souheil Bejaoui +* Denis Roussel +* Jacques-Étienne Baudoux + +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. + +This module is part of the `OCA/wms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor_mobile_packing/__init__.py b/shopfloor_mobile_packing/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/shopfloor_mobile_packing/__manifest__.py b/shopfloor_mobile_packing/__manifest__.py new file mode 100644 index 00000000000..b421f7e36ca --- /dev/null +++ b/shopfloor_mobile_packing/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopfloor Mobile Packing", + "version": "16.0.1.0.0", + "summary": """ + Shopfloor mobile extension for packing operation into cluster picking + """, + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/wms", + "category": "Stock Management", + "depends": ["shopfloor_mobile", "shopfloor_packing"], + "data": ["templates/assets.xml"], + "installable": True, + "license": "AGPL-3", + "application": False, +} diff --git a/shopfloor_mobile_packing/readme/CONTRIBUTORS.rst b/shopfloor_mobile_packing/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..c5cd537df94 --- /dev/null +++ b/shopfloor_mobile_packing/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Souheil Bejaoui +* Denis Roussel +* Jacques-Étienne Baudoux diff --git a/shopfloor_mobile_packing/readme/DESCRIPTION.rst b/shopfloor_mobile_packing/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..a5c474fc17e --- /dev/null +++ b/shopfloor_mobile_packing/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Shopfloor mobile extension for packing operation into cluster picking. diff --git a/shopfloor_mobile_packing/static/description/index.html b/shopfloor_mobile_packing/static/description/index.html new file mode 100644 index 00000000000..59b4d73d051 --- /dev/null +++ b/shopfloor_mobile_packing/static/description/index.html @@ -0,0 +1,425 @@ + + + + + +Shopfloor Mobile Packing + + + +
+

Shopfloor Mobile Packing

+ + +

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

+

Shopfloor mobile extension for packing operation into cluster picking.

+

Table of contents

+ +
+

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

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

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.

+

This module is part of the OCA/wms project on GitHub.

+

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

+
+
+
+ + diff --git a/shopfloor_mobile_packing/static/src/js/cluster-picking.js b/shopfloor_mobile_packing/static/src/js/cluster-picking.js new file mode 100644 index 00000000000..d592f2c50a9 --- /dev/null +++ b/shopfloor_mobile_packing/static/src/js/cluster-picking.js @@ -0,0 +1,352 @@ +/** + * Copyright 2021 ACSONE SA/NV + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +import {process_registry} from "/shopfloor_mobile_base/static/wms/src/services/process_registry.js"; + +const registry_key = "cluster_picking"; +const ClusterPickingBase = process_registry.get(registry_key); + +let template = ClusterPickingBase.component.template; +ClusterPickingBase.component.template = template.replace( + "", + ` + + +
+ +

+ + + +

+ + + + New pack + + + + + Process w/o pack + + +
+
+
+ + +
+ + + + + +
+
+ +` +); + +// Keep the pointer to the orginal method +let data_result_method = ClusterPickingBase.component.data; + +ClusterPickingBase.component.computed.searchbar_input_type = function () { + if (this.state_is("pack_picking_put_in_pack")) { + return "number"; + } + return "text"; +}; + +ClusterPickingBase.component.computed.existing_package_select_fields = function () { + return [ + {path: "weight"}, + {path: "move_line_count", label: "Line count"}, + {path: "packaging.name"}, + ]; +}; + +ClusterPickingBase.component.methods.select_package_manual_select_opts = function () { + return { + multiple: true, + initValue: this.selected_line_ids(), + card_klass: "loud-labels", + list_item_component: "picking-select-package-content", + list_item_options: {actions: ["action_qty_edit"]}, + }; +}; + +ClusterPickingBase.component.methods.select_delivery_packaging_manual_select_options = + function () { + return { + showActions: false, + }; + }; + +ClusterPickingBase.component.methods.selected_line_ids = function () { + return this.selected_lines().map(_.property("id")); +}; + +ClusterPickingBase.component.methods.selected_line_ids_for_packing = function () { + return this.selectable_lines_for_packing().map(_.property("id")); +}; + +ClusterPickingBase.component.methods.selectable_lines = function () { + const stored = this.state_get_data("select_package"); + return _.result(stored, "selected_move_lines", []); +}; + +ClusterPickingBase.component.methods.selectable_line_ids = function () { + return this.selectable_lines().map(_.property("id")); +}; + +ClusterPickingBase.component.methods.selected_lines = function () { + return this.selectable_lines().filter(function (x) { + return x.qty_done > 0; + }); +}; + +ClusterPickingBase.component.methods.selectable_lines_for_packing = function () { + const lines = this.state.data.selected_lines_for_packing; + return lines; +}; + +// Replace the data method with our new method to add +// our new state +let component = ClusterPickingBase.component; +let data = function () { + // we must bin the original method to this to put it into + // the object context + let result = data_result_method.bind(this)(); + // add our new state + result.states.pack_picking_put_in_pack = { + display_info: { + title: this.$t("cluster_picking.pack_picking_put_in_pack.title"), + scan_placeholder: this.$t( + "cluster_picking.pack_picking_put_in_pack.scan_placeholder" + ), + }, + on_scan: (scanned) => { + let endpoint, endpoint_data; + const data = this.state.data; + endpoint = "put_in_pack"; + endpoint_data = { + picking_batch_id: this.current_batch().id, + picking_id: data.id, + nbr_packages: parseInt(scanned.text, 10), + }; + this.wait_call(this.odoo.call(endpoint, endpoint_data)); + }, + }; + result.states.pack_picking_scan_pack = { + display_info: { + title: this.$t("cluster_picking.pack_picking_scan_pack.title"), + scan_placeholder: this.$t( + "cluster_picking.pack_picking_scan_pack.scan_placeholder" + ), + }, + on_scan: (scanned) => { + let endpoint, endpoint_data; + const data = this.state.data; + endpoint = "scan_packing_to_pack"; + endpoint_data = { + picking_batch_id: this.current_batch().id, + picking_id: data.id, + barcode: scanned.text, + }; + this.wait_call(this.odoo.call(endpoint, endpoint_data)); + }, + }; + result.states.select_package = { + // TODO: /set_line_qty is not handled yet + // because is not clear how to handle line selection + // and qty set. + // ATM given that manual-select uses v-list-item-group + // when you touch a line you select/unselect it + // which means we cannot rely on this to go to edit. + // If we need it, we have to change manual-select + // to use pure list + checkboxes. + display_info: { + title: "Select package", + scan_placeholder: "Scan existing package / package type", + }, + events: { + qty_edit: "on_qty_edit", + select: "on_select", + back: "on_back", + }, + on_scan: (scanned) => { + this.wait_call( + this.odoo.call("scan_package_action", { + picking_id: this.state.data.picking.id, + selected_line_ids: this.selectable_line_ids(), + barcode: scanned.text, + }) + ); + }, + on_select: (selected) => { + return; + // TODO: + // if (!selected) { + // return; + // } + // const orig_selected = $instance.selected_line_ids(); + // const selected_ids = selected.map(_.property("id")); + // const to_select = _.head( + // $instance.selectable_lines().filter(function (x) { + // return selected_ids.includes(x.id) && !orig_selected.includes(x.id); + // }) + // ); + // const to_unselect = _.head( + // $instance.selectable_lines().filter(function (x) { + // return !selected_ids.includes(x.id) && orig_selected.includes(x.id); + // }) + // ); + // let endpoint, move_line; + // if (to_unselect) { + // endpoint = "reset_line_qty"; + // move_line = to_unselect; + // } else if (to_select) { + // endpoint = "set_line_qty"; + // move_line = to_select; + // } + // $instance.wait_call( + // $instance.odoo.call(endpoint, { + // picking_id: $instance.state.data.picking.id, + // selected_line_ids: $instance.selectable_line_ids(), + // move_line_id: move_line.id, + // }) + // ); + }, + on_qty_edit: (record) => { + return; + // TODO: + // $instance.state_set_data( + // { + // picking: $instance.state.data.picking, + // line: record, + // selected_line_ids: $instance.selectable_line_ids(), + // }, + // "change_quantity" + // ); + // $instance.state_to("change_quantity"); + }, + on_new_pack: () => { + /** + * Trigger the call to list delivery package types + * as user wants to put products in a new pack. + */ + let endpoint, endpoint_data; + const data = this.state.data; + endpoint = "list_delivery_package_types"; + endpoint_data = { + picking_batch_id: this.current_batch().id, + picking_id: data.picking.id, + selected_line_ids: this.selectable_line_ids(), + }; + this.wait_call(this.odoo.call(endpoint, endpoint_data)); + }, + on_existing_pack: () => { + return; + // TODO: + // $instance.wait_call( + // $instance.odoo.call("list_dest_package", { + // picking_id: $instance.state.data.picking.id, + // selected_line_ids: $instance.selectable_line_ids(), + // }) + // ); + }, + on_without_pack: () => { + return; + // TODO: + // $instance.wait_call( + // $instance.odoo.call("no_package", { + // picking_id: $instance.state.data.picking.id, + // selected_line_ids: $instance.selectable_line_ids(), + // }) + // ); + }, + on_back: () => { + $instance.state_to("select_line"); + $instance.reset_notification(); + }, + }; + result.states.select_delivery_packaging = { + /** + * This will catch user events when selecting the delivery packaging type + * from: + * - the scanned barcode + * - the direct selection on screen + */ + display_info: { + title: "Select delivery packaging or scan it", + scan_placeholder: "Scan package type", + }, + events: { + select: "on_select", + back: "on_back", + }, + on_select: (selected) => { + const picking = this.current_doc().record; + const data = this.state.data.picking; + const ids = this.selected_line_ids_for_packing(); + this.wait_call( + this.odoo.call("put_in_pack", { + picking_batch_id: this.current_batch().id, + picking_id: data.id, + selected_line_ids: ids, + package_type_id: selected.id, + }) + ); + }, + on_scan: (scanned) => { + const picking = this.current_doc().record; + const data = this.state.data; + this.wait_call( + this.odoo.call("scan_package_action", { + picking_id: data.picking.id, + selected_line_ids: this.selected_line_ids(), + barcode: scanned.text, + }) + ); + }, + }; + + return result; +}; + +ClusterPickingBase.component.data = data; diff --git a/shopfloor_mobile_packing/static/src/js/components/pack_picking.js b/shopfloor_mobile_packing/static/src/js/components/pack_picking.js new file mode 100644 index 00000000000..b4101a2d3ab --- /dev/null +++ b/shopfloor_mobile_packing/static/src/js/components/pack_picking.js @@ -0,0 +1,94 @@ +/** + * Copyright 2021 ACSONE SA/NV + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +/* eslint-disable strict */ +Vue.component("pack-picking", { + props: ["record"], + methods: { + move_lines_color_klass(rec) { + let line = rec; + if (line._is_group) { + line = line.records[0]; + } + let klass = ""; + if (this.record.scanned_packs.includes(line.package_dest.id)) { + klass = "done screen_step_done lighten-1"; + } else { + klass = "not-done screen_step_todo lighten-1"; + } + return "move-line-" + klass; + }, + line_list_options() { + return { + card_klass: "loud-labels", + key_title: "", + showCounters: false, + list_item_options: { + fields: this.line_list_fields(), + list_item_klass_maker: this.move_lines_color_klass, + }, + }; + }, + line_list_fields() { + self = this; + return [ + { + path: "product.display_name", + action_val_path: "product.default_code", + klass: "loud", + }, + { + path: "package_src.name", + label: "Pack", + action_val_path: "package_src.name", + }, + {path: "lot.name", label: "Lot", action_val_path: "lot.name"}, + { + path: "qty_done", + label: "Qty", + render_component: "packaging-qty-picker-display", + render_props: function (record) { + return self.utils.wms.move_line_qty_picker_props(record, { + qtyInit: record.qty_done, + }); + }, + }, + ]; + }, + grouped_lines() { + const groups = this.utils.wms.group_by_pack( + this.record.move_lines.filter((op) => { + if (op.package_dest != null && op.package_dest.is_internal) { + return op; + } + }) + ); + const self = this; + _.forEach(groups, function (item) { + item.group_color = self.record.scanned_packs.includes(item.pack.id) + ? self.utils.colors.color_for("screen_step_done") + : self.utils.colors.color_for("screen_step_todo"); + }); + return groups; + }, + }, + template: ` +
+ + +
+ {{ record.name }} : {{ record.partner.name }} +
+
+
+ +
+ +`, +}); diff --git a/shopfloor_mobile_packing/static/src/js/i18n/en.json b/shopfloor_mobile_packing/static/src/js/i18n/en.json new file mode 100644 index 00000000000..440c46a98aa --- /dev/null +++ b/shopfloor_mobile_packing/static/src/js/i18n/en.json @@ -0,0 +1,6 @@ +{ + "cluster_picking.pack_picking_put_in_pack.title": "Packing: Enter the number of boxes used.", + "cluster_picking.pack_picking_put_in_pack.scan_placeholder": "Number of boxes used", + "cluster_picking.pack_picking_scan_pack.title": "Packing: Validate bin to pack.", + "cluster_picking.pack_picking_scan_pack.scan_placeholder": "Bin barcode " +} diff --git a/shopfloor_mobile_packing/static/src/js/i18n/fr.json b/shopfloor_mobile_packing/static/src/js/i18n/fr.json new file mode 100644 index 00000000000..cad33dee5dc --- /dev/null +++ b/shopfloor_mobile_packing/static/src/js/i18n/fr.json @@ -0,0 +1,6 @@ +{ + "cluster_picking.pack_picking_put_in_pack.title": "Emballage: Nombre de colis.", + "cluster_picking.pack_picking_put_in_pack.scan_placeholder": "Nombre de colis créés?", + "cluster_picking.pack_picking_scan_pack.title": "Emballage: Validation emplacement à emballer.", + "cluster_picking.pack_picking_scan_pack.scan_placeholder": "Code-barre emplacement charriot " +} diff --git a/shopfloor_mobile_packing/static/src/js/translation_registry.js b/shopfloor_mobile_packing/static/src/js/translation_registry.js new file mode 100644 index 00000000000..c8e11f06f69 --- /dev/null +++ b/shopfloor_mobile_packing/static/src/js/translation_registry.js @@ -0,0 +1,10 @@ +import {translation_registry} from "/shopfloor_mobile_base/static/wms/src/services/translation_registry.js"; + +translation_registry.load( + "fr-FR", + "/shopfloor_mobile_packing/static/src/js/i18n/fr.json" +); +translation_registry.load( + "en-US", + "/shopfloor_mobile_packing/static/src/js/i18n/en.json" +); diff --git a/shopfloor_mobile_packing/templates/assets.xml b/shopfloor_mobile_packing/templates/assets.xml new file mode 100644 index 00000000000..49352669958 --- /dev/null +++ b/shopfloor_mobile_packing/templates/assets.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/shopfloor_packing/README.rst b/shopfloor_packing/README.rst new file mode 100644 index 00000000000..6d559bfa521 --- /dev/null +++ b/shopfloor_packing/README.rst @@ -0,0 +1,92 @@ +================= +Shopfloor Packing +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:080d1fec7bcb7e9bca4d18efb9e6fa02033a67cbb90f888c4ae3071cb7362f43 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github + :target: https://github.com/OCA/wms/tree/16.0/shopfloor_packing + :alt: OCA/wms +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/wms-16-0/wms-16-0-shopfloor_packing + :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/wms&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +In a two-step delivery process (or more), packing is a major task. +It is essential that the picking scenario helps operators to put products from +the same order into packages, when collecting multiple orders at the same time. + +This module adds a new configuration option to the "cluster picking" scenario, +enabling the packing of pickings into packages. +When this option is activated, the operator will be prompted to pack the +products of the same picking. +In the background, a package will be created and set as the destination for +the stock move lines. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Go to the "cluster picking" scenario and check "Pack pickings". + +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 +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Souheil Bejaoui +* Denis Roussel +* Jacques-Étienne Baudoux + +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. + +This module is part of the `OCA/wms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor_packing/__init__.py b/shopfloor_packing/__init__.py new file mode 100644 index 00000000000..c6efb50ad34 --- /dev/null +++ b/shopfloor_packing/__init__.py @@ -0,0 +1,3 @@ +from . import actions +from . import services +from . import models diff --git a/shopfloor_packing/__manifest__.py b/shopfloor_packing/__manifest__.py new file mode 100644 index 00000000000..32b1c8f1f42 --- /dev/null +++ b/shopfloor_packing/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Shopfloor Packing", + "version": "16.0.1.0.0", + "summary": """ Manage Packing into cluster picking""", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/wms", + "category": "Stock Management", + "depends": [ + "shopfloor", + "internal_stock_quant_package", + "delivery_package_type_number_parcels", + "stock_picking_delivery_package_type_domain", + ], + "data": ["views/shopfloor_menu.xml", "views/stock_picking.xml"], + "installable": True, + "license": "AGPL-3", + "application": False, +} diff --git a/shopfloor_packing/actions/__init__.py b/shopfloor_packing/actions/__init__.py new file mode 100644 index 00000000000..7bb61dcadf7 --- /dev/null +++ b/shopfloor_packing/actions/__init__.py @@ -0,0 +1,3 @@ +from . import data +from . import schema +from . import message diff --git a/shopfloor_packing/actions/data.py b/shopfloor_packing/actions/data.py new file mode 100644 index 00000000000..1b95c95cfd7 --- /dev/null +++ b/shopfloor_packing/actions/data.py @@ -0,0 +1,61 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component +from odoo.addons.shopfloor_base.utils import ensure_model + + +class DataAction(Component): + _inherit = "shopfloor.data.action" + + @property + def _package_parser(self): + res = super()._package_parser + res.append("is_internal") + return res + + def _data_for_packing_info(self, picking): + """Return the packing information + + Intended to be extended. + """ + # TODO: This could be avoided if included in the picking parser. + return "" + + def select_package(self, picking, lines): + return { + "selected_move_lines": self.move_lines(lines.sorted()), + "picking": self.picking(picking), + "packing_info": self._data_for_packing_info(picking), + # "no_package_enabled": not self.options.get("checkout__disable_no_package"), + # Used by inheriting module + "package_allowed": True, + } + + @ensure_model("stock.picking") + def pack_picking(self, record, **kw): + return { + "id": record.id, + "name": record.name, + "partner": {"id": record.partner_id.id, "name": record.partner_id.name}, + "scanned_packs": list(record._packing_scanned_packs), + "move_lines": [ + self._pack_picking_move_lines(ml) for ml in record.move_line_ids + ], + } + + def _pack_picking_move_lines(self, record): + return { + "id": record.id, + "qty_done": record.qty_done, + "product": self.product( + record.product_id or record.package_id.single_product_id + ), + "package_src": self.package(record.package_id, record.picking_id), + "package_dest": self.package( + record.result_package_id.with_context( + picking_id=record.picking_id.id, no_quantity=True + ), + record.picking_id, + ), + } diff --git a/shopfloor_packing/actions/message.py b/shopfloor_packing/actions/message.py new file mode 100644 index 00000000000..b8de0f846a7 --- /dev/null +++ b/shopfloor_packing/actions/message.py @@ -0,0 +1,49 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _ + +from odoo.addons.component.core import Component + + +class MessageAction(Component): + _inherit = "shopfloor.message.action" + + def stock_picking_already_packed(self, picking): + return { + "message_type": "warning", + "body": _("Transfer {} already packed.").format(picking.name), + } + + def nbr_packages_must_be_greated_than_zero(self): + return { + "message_type": "error", + "body": _("The number of packages must be greater than 0."), + } + + def notable_to_put_in_pack(self, picking): + return { + "message_type": "error", + "body": _("Not able to put in pack transfer {}.").format(picking.name), + } + + def bin_should_be_internal(self, package): + return { + "message_type": "error", + "body": _("The scanned package '{}' must be internal.").format( + package.name + ), + } + + def bin_is_for_another_picking(self, package): + return { + "message_type": "error", + "body": _("The scanned package '{}' is for an other picking.").format( + package.name + ), + } + + def stock_picking_packed_successfully(self, picking): + return { + "message_type": "success", + "body": _("Transfer {} has been packed successfully.").format(picking.name), + } diff --git a/shopfloor_packing/actions/schema.py b/shopfloor_packing/actions/schema.py new file mode 100644 index 00000000000..ffbed5b9eec --- /dev/null +++ b/shopfloor_packing/actions/schema.py @@ -0,0 +1,79 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class ShopfloorSchemaAction(Component): + + _inherit = "shopfloor.schema.action" + + def package(self, with_packaging=False): + schema = super().package(with_packaging=with_packaging) + schema["is_internal"] = {"required": False, "type": "boolean"} + return schema + + def select_package(self) -> dict: + """ + This will return the schema expected to display the action to select the + package to put in. + """ + schema = { + "selected_move_lines": { + "type": "list", + "schema": self._schema_dict_of(self.move_line()), + }, + "picking": self._schema_dict_of(self.picking()), + "packing_info": {"type": "string", "nullable": True}, + "no_package_enabled": { + "type": "boolean", + "nullable": True, + "required": False, + }, + "package_allowed": { + "type": "boolean", + "nullable": True, + "required": False, + }, + } + return schema + + def pack_picking(self): + schema = { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "required": True, "nullable": False}, + "partner": { + "type": "dict", + "required": True, + "nullable": False, + "schema": { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "required": True, "nullable": False}, + }, + }, + "scanned_packs": {"type": "list", "schema": {"type": "integer"}}, + "move_lines": { + "type": "list", + "schema": { + "type": "dict", + "schema": { + "id": {"required": True, "type": "integer"}, + "qty_done": {"type": "float", "required": True}, + "lot": { + "type": "dict", + "required": False, + "nullable": True, + "schema": self.lot(), + }, + "package_dest": self._schema_dict_of( + self.package(with_packaging=False), required=False + ), + "package_src": self._schema_dict_of( + self.package(with_packaging=False), required=False + ), + "product": self._schema_dict_of(self.product()), + }, + }, + }, + } + return schema diff --git a/shopfloor_packing/i18n/fr.po b/shopfloor_packing/i18n/fr.po new file mode 100644 index 00000000000..14c338b81ef --- /dev/null +++ b/shopfloor_packing/i18n/fr.po @@ -0,0 +1,95 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_packing +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 10.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-12-09 07:21+0000\n" +"PO-Revision-Date: 2021-12-09 07:21+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: shopfloor_packing +#: model:ir.model.fields,help:shopfloor_packing.field_stock_picking_is_shopfloor_packing_todo +#: model:ir.model.fields,help:shopfloor_packing.field_stock_picking_wave_is_shopfloor_packing_todo +msgid "If set, some operations need to be packed by the operator" +msgstr "Si coché, le contenu des bacs doit encore être mis en boîte par l'opérateur" + +#. module: shopfloor_packing +#: model:ir.model.fields,help:shopfloor_packing.field_shopfloor_menu_pack_pickings +msgid "If you tick this box, all the picked item will be put in pack before the transfer." +msgstr "Si cochée, les produits devront être mis en boîte à la fin de la préparation." + +#. module: shopfloor_packing +#: model:ir.model,name:shopfloor_packing.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "Menu affiché dans l'application du scanner" + +#. module: shopfloor_packing +#: code:addons/shopfloor_packing/actions/message.py:27 +#, python-format +msgid "Not able to put in pack transfer {}." +msgstr "Impossible d'emballer le transfert {}." + +#. module: shopfloor_packing +#: model:ir.model.fields,field_description:shopfloor_packing.field_stock_picking_is_shopfloor_packing_todo +#: model:ir.model.fields,field_description:shopfloor_packing.field_stock_picking_wave_is_shopfloor_packing_todo +msgid "Operations need to be packed" +msgstr "Des produits doivent être emballés" + +#. module: shopfloor_packing +#: model:ir.model.fields,field_description:shopfloor_packing.field_shopfloor_menu_pack_pickings +msgid "Pack pickings" +msgstr "Emballer les préparations" + +#. module: shopfloor_packing +#: code:addons/shopfloor_packing/models/shopfloor_menu.py:33 +#, python-format +msgid "Pack pickings is not allowed for menu {}." +msgstr "L'emballage des préparations n'est pas supporté par le menu {}." + +#. module: shopfloor_packing +#: model:ir.model.fields,field_description:shopfloor_packing.field_shopfloor_menu_pack_pickings_is_possible +msgid "Pack pickings is possible" +msgstr "L'emballage des préparations n'est pas possible" + +#. module: shopfloor_packing +#: model:ir.model,name:shopfloor_packing.model_stock_picking_wave +msgid "Picking Wave" +msgstr "Vague de préparation" + +#. module: shopfloor_packing +#: code:addons/shopfloor_packing/actions/message.py:21 +#, python-format +msgid "The number of packages must be greater than 0." +msgstr "Le nombre de boîtes doit est > 0." + +#. module: shopfloor_packing +#: code:addons/shopfloor_packing/actions/message.py:33 +#, python-format +msgid "The scanned package '{}' must be internal." +msgstr "The scanned package '{}' must be internal." + +#. module: shopfloor_packing +#: model:ir.model,name:shopfloor_packing.model_stock_picking +msgid "Transfer" +msgstr "Transfert" + +#. module: shopfloor_packing +#: code:addons/shopfloor_packing/actions/message.py:15 +#, python-format +msgid "Transfer {} already packed." +msgstr "La préparation {} est déjà emballée." + + +#. module: shopfloor_packing +#: code:addons/shopfloor_packing/actions/message.py:49 +#, python-format +msgid "Transfer {} has been packed successfully." +msgstr "La préparation {} a été emballée avec succès." diff --git a/shopfloor_packing/models/__init__.py b/shopfloor_packing/models/__init__.py new file mode 100644 index 00000000000..196a4062338 --- /dev/null +++ b/shopfloor_packing/models/__init__.py @@ -0,0 +1,4 @@ +from . import stock_picking +from . import stock_picking_batch +from . import shopfloor_menu +from . import stock_quant_package diff --git a/shopfloor_packing/models/shopfloor_menu.py b/shopfloor_packing/models/shopfloor_menu.py new file mode 100644 index 00000000000..46b1d0a8e1d --- /dev/null +++ b/shopfloor_packing/models/shopfloor_menu.py @@ -0,0 +1,23 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ShopfloorMenu(models.Model): + _inherit = "shopfloor.menu" + + pack_pickings = fields.Boolean( + string="Pack pickings", + default=False, + help="If you tick this box, all the picked items will be put in a new pack" + " at the end of the picking process.", + ) + + default_pack_pickings_action = fields.Selection( + [ + ("nbr_packages", "Enter the number of packages"), + ("package_type", "Scan the package type"), + ], + default="nbr_packages", + ) diff --git a/shopfloor_packing/models/stock_picking.py b/shopfloor_packing/models/stock_picking.py new file mode 100644 index 00000000000..aadef90a003 --- /dev/null +++ b/shopfloor_packing/models/stock_picking.py @@ -0,0 +1,78 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json + +from odoo import api, fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + is_shopfloor_packing_todo = fields.Boolean( + "Operations need to be packed", + help="If set, some operations need to be packed by the shopdloor operator", + compute="_compute_is_shopfloor_packing_todo", + ) + + packing_scanned_packs_str = fields.Char( + help="Technical field to know which pack has been scanned into the put in " + "pack process" + ) + + def _get_packages_to_pack(self): + self.ensure_one() + return self.mapped("move_line_ids.result_package_id").filtered("is_internal") + + @api.depends("move_line_ids", "move_line_ids.result_package_id") + def _compute_is_shopfloor_packing_todo(self): + for rec in self: + rec.is_shopfloor_packing_todo = False + for move_line in rec.move_line_ids: + if ( + move_line.result_package_id + and move_line.result_package_id.is_internal + ): + rec.is_shopfloor_packing_todo = True + break + + @property + def _packing_scanned_packs(self): + return set(json.loads(self.packing_scanned_packs_str or "[]")) + + def _set_packing_scanned_packs(self, packing_scanned_packs): + scanned_packs = list(packing_scanned_packs) if packing_scanned_packs else [] + self.packing_scanned_packs_str = json.dumps(scanned_packs) + + def _set_packing_pack_scanned(self, pack_id): + self.ensure_one() + self._set_packing_scanned_packs(self._packing_scanned_packs | {pack_id}) + + def _is_packing_pack_scanned(self, pack_id): + self.ensure_one() + return pack_id in self._packing_scanned_packs + + def _reset_packing_packs_scanned(self): + for rec in self: + rec._set_packing_scanned_packs({}) + + def is_shopfloor_packing_pack_to_scan(self): + self.ensure_one() + return set(self._get_packages_to_pack().ids) != self._packing_scanned_packs + + def _put_in_pack(self, move_line_ids, create_package_level=True): + """ + Reset the result package after partial put in pack + If a new package was created and a move line kept its old package, it means the + "put in pack" action created a new line for the done quantity. + The original line should be reset. + """ + new_package = super()._put_in_pack(move_line_ids, create_package_level) + for move_line in move_line_ids: + if ( + new_package + and move_line.result_package_id + and move_line.result_package_id != new_package + ): + move_line.result_package_id = False + return new_package diff --git a/shopfloor_packing/models/stock_picking_batch.py b/shopfloor_packing/models/stock_picking_batch.py new file mode 100644 index 00000000000..44a5c95c4e8 --- /dev/null +++ b/shopfloor_packing/models/stock_picking_batch.py @@ -0,0 +1,21 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class StockPickingBatch(models.Model): + _inherit = "stock.picking.batch" + + is_shopfloor_packing_todo = fields.Boolean( + "Operations need to be packed", + help="If set, some operations need to be packed by the shopfloor operator", + compute="_compute_is_shopfloor_packing_todo", + ) + + @api.depends("picking_ids", "picking_ids.is_shopfloor_packing_todo") + def _compute_is_shopfloor_packing_todo(self): + for rec in self: + rec.is_shopfloor_packing_todo = any( + rec.picking_ids.mapped("is_shopfloor_packing_todo") + ) diff --git a/shopfloor_packing/models/stock_quant_package.py b/shopfloor_packing/models/stock_quant_package.py new file mode 100644 index 00000000000..bf6d57ae0ed --- /dev/null +++ b/shopfloor_packing/models/stock_quant_package.py @@ -0,0 +1,13 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockQuantPackage(models.Model): + _inherit = "stock.quant.package" + + def _sync_package_type_from_single_product(self): + return super( + StockQuantPackage, self.filtered(lambda p: not p.number_of_parcels) + )._sync_package_type_from_single_product() diff --git a/shopfloor_packing/readme/CONTRIBUTORS.rst b/shopfloor_packing/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..c5cd537df94 --- /dev/null +++ b/shopfloor_packing/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Souheil Bejaoui +* Denis Roussel +* Jacques-Étienne Baudoux diff --git a/shopfloor_packing/readme/DESCRIPTION.rst b/shopfloor_packing/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..c1091d6ad45 --- /dev/null +++ b/shopfloor_packing/readme/DESCRIPTION.rst @@ -0,0 +1,10 @@ +In a two-step delivery process (or more), packing is a major task. +It is essential that the picking scenario helps operators to put products from +the same order into packages, when collecting multiple orders at the same time. + +This module adds a new configuration option to the "cluster picking" scenario, +enabling the packing of pickings into packages. +When this option is activated, the operator will be prompted to pack the +products of the same picking. +In the background, a package will be created and set as the destination for +the stock move lines. diff --git a/shopfloor_packing/readme/USAGE.rst b/shopfloor_packing/readme/USAGE.rst new file mode 100644 index 00000000000..904eb9ccaa0 --- /dev/null +++ b/shopfloor_packing/readme/USAGE.rst @@ -0,0 +1 @@ +Go to the "cluster picking" scenario and check "Pack pickings". diff --git a/shopfloor_packing/services/__init__.py b/shopfloor_packing/services/__init__.py new file mode 100644 index 00000000000..265d3f60038 --- /dev/null +++ b/shopfloor_packing/services/__init__.py @@ -0,0 +1 @@ +from . import cluster_picking, packing diff --git a/shopfloor_packing/services/cluster_picking.py b/shopfloor_packing/services/cluster_picking.py new file mode 100644 index 00000000000..cef54dd5c98 --- /dev/null +++ b/shopfloor_packing/services/cluster_picking.py @@ -0,0 +1,606 @@ +# Copyright 2021 ACSONE SA/NV (https://www.acsone.eu) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields + +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component +from odoo.addons.stock.models.stock_move_line import StockMoveLine +from odoo.addons.stock.models.stock_package_type import PackageType +from odoo.addons.stock.models.stock_picking import Picking +from odoo.addons.stock.models.stock_quant import QuantPackage + +from .packing import PackingAction + + +class ClusterPicking(Component): + + _inherit = "shopfloor.cluster.picking" + + # PUBLIC METHODS - ENDPOINTS + + def list_delivery_package_types( + self, picking_batch_id, picking_id, selected_line_ids + ) -> dict: + """List available delivery package types for given picking. + + Transitions: + * select_delivery_package_types: list available delivery package types, the + user has to choose one to create the new package + * select_package: when no delivery package types are available + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_start(message=message) + selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + delivery_packaging = self._get_available_delivery_package_type(picking) + if not delivery_packaging: + return self._response_for_select_package( + picking, + selected_lines, + message=self.msg_store.no_delivery_packaging_available(), + ) + response = self._check_allowed_qty_done(picking, selected_lines) + if response: + return response + return self._response_for_select_delivery_package_type( + picking, delivery_packaging, selected_lines + ) + + def scan_package_action(self, picking_id, selected_line_ids, barcode) -> dict: + """Scan a package, a lot, a product or a package to handle a line + + When a package is scanned (only delivery ones), if the package is known + as the destination package of one of the lines or is the source package + of a selected line, the package is set to be the destination package of + all the lines to pack. + + When a product is scanned, it selects (set qty_done = reserved qty) or + deselects (set qty_done = 0) the move lines for this product. Only + products not tracked by lot can use this. + + When a lot is scanned, it does the same as for the products but based + on the lot. + + When a package type is scanned, a new + package is created and set as destination of the lines to pack. + + Lines to pack are move lines in the list of ``selected_line_ids`` + where ``qty_done`` > 0 and have not been packed yet + (``shopfloor_checkout_done is False``). + + Transitions: + * select_package: when a product or lot is scanned to select/deselect, + the client app has to show the same screen with the updated selection + * select_line: when a package or packaging type is scanned, move lines + have been put in package and we can return back to this state to handle + the other lines + * summary: if there is no other lines, go to the summary screen to be able + to close the stock picking + """ + packing_action: PackingAction = self._actions_for("packing") + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + + selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + search_result = packing_action._scan_package_find(picking, barcode) + message = packing_action._check_scan_package_find(picking, search_result) + if message: + return self._response_for_select_package( + picking, + selected_lines, + message=message, + ) + if search_result and search_result.type == "delivery_packaging": + package_type_id = search_result.record.id + else: + return self._response_for_select_package( + picking, + selected_lines, + message=self.msg_store.package_not_found_for_barcode(barcode), + ) + # Call the specific put in pack with package type filled in + return self.put_in_pack( + picking.batch_id.id, + picking.id, + selected_line_ids=selected_line_ids, + package_type_id=package_type_id, + ) + + def scan_destination_pack( + self, picking_batch_id, move_line_id, barcode, quantity + ) -> dict: + search = self._actions_for("search") + bin_package = search.package_from_scan(barcode) + + if bin_package and not bin_package.is_internal: + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._pick_next_line( + batch, message=self.msg_store.operation_not_found() + ) + return self._response_for_scan_destination( + move_line, message=self.msg_store.bin_should_be_internal(bin_package) + ) + return super().scan_destination_pack( + picking_batch_id, move_line_id, barcode, quantity + ) + + def scan_packing_to_pack(self, picking_batch_id, picking_id, barcode) -> dict: + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + picking = batch.picking_ids.filtered( + lambda p, picking_id=picking_id: p.id == picking_id + ) + if not picking: + return self._prepare_pack_picking( + batch, + message=self.msg_store.stock_picking_not_found(), + ) + if not picking.is_shopfloor_packing_todo: + return self._prepare_pack_picking( + batch, + message=self.msg_store.stock_picking_already_packed(picking), + ) + + search = self._actions_for("search") + bin_package = search.package_from_scan(barcode) + + if not bin_package: + return self._prepare_pack_picking( + batch, message=self.msg_store.bin_not_found_for_barcode(barcode) + ) + if not bin_package.is_internal: + return self._prepare_pack_picking( + batch, message=self.msg_store.bin_should_be_internal(bin_package) + ) + if bin_package not in picking.mapped("move_line_ids.result_package_id"): + return self._prepare_pack_picking( + batch, message=self.msg_store.bin_is_for_another_picking(bin_package) + ) + + picking._set_packing_pack_scanned(bin_package.id) + return self._prepare_pack_picking( + batch, + ) + + def prepare_unload(self, picking_batch_id) -> dict: + # before initializing the unloading phase we put picking in pack if + # required by the scenario + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + if not self.work.menu.pack_pickings or not batch.is_shopfloor_packing_todo: + return super().prepare_unload(picking_batch_id) + return self._prepare_pack_picking(batch) + + def put_in_pack( + self, + picking_batch_id, + picking_id, + selected_line_ids, + nbr_packages=None, + package_type_id=None, + ) -> dict: + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + picking = batch.picking_ids.filtered( + lambda p, picking_id=picking_id: p.id == picking_id + ) + + package_type = self.env["stock.package.type"].browse(package_type_id) + if not package_type and nbr_packages: + package_type = self._get_suitable_package_type(nbr_packages) + if package_type: + package_type_id = package_type.id + nbr_packages = None + + # Check if parameters are correct + packing_action: PackingAction = self._actions_for("packing") + result = packing_action._check_put_in_pack( + picking_batch_id, + picking, + self._response_put_in_pack, + nbr_packages=nbr_packages, + package_type_id=package_type_id, + ) + if result: + return result + + lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + savepoint = self._actions_for("savepoint").new() + pack = self._put_in_pack(picking, lines, nbr_packages, package_type_id) + picking._reset_packing_packs_scanned() + if not pack: + savepoint.rollback() + return self._response_put_in_pack( + picking_batch_id, + message=self.msg_store.notable_to_put_in_pack(picking), + ) + self._postprocess_put_in_pack(picking, pack) + return self._response_put_in_pack( + picking_batch_id, + message=self.msg_store.stock_picking_packed_successfully(picking), + ) + + # HELPER METHODS + def _get_suitable_package_type(self, number_of_parcels): + return self.env["stock.package.type"].search( + [ + ("number_of_parcels", "=", number_of_parcels), + ("package_carrier_type", "=", "none"), + ], + limit=1, + ) + + @property + def default_pick_pack_action(self): + return self.work.menu.default_pack_pickings_action + + def _get_available_delivery_package_type(self, picking) -> PackageType: + """ + This returns available packages types for the carrier if defined + or package types that have "none" delivery type. + + The returned package types are ordered by number of parcels then + by name. + """ + model = self.env["stock.package.type"] + carrier = picking.ship_carrier_id or picking.carrier_id + wizard_obj = self.env["choose.delivery.package"] + delivery_type = ( + carrier.delivery_type + if carrier.delivery_type not in ("fixed", False) + else "none" + ) + wizard = wizard_obj.with_context( + current_package_carrier_type=delivery_type + ).new({"picking_id": picking.id}) + if not carrier: + return model.browse() + return model.search( + wizard.package_type_domain, + order="number_of_parcels,name", + ) + + def _last_picked_line(self, picking) -> StockMoveLine: + # a complete override to add a condition on internal package + # TODO: Add a hook to avoid re-writing this + return fields.first( + picking.move_line_ids.filtered( + lambda l: l.qty_done > 0 + and l.result_package_id.is_internal + # if we are moving the entire package, we shouldn't + # add stuff inside it, it's not a new package + and l.package_id != l.result_package_id + ).sorted(key="write_date", reverse=True) + ) + + def _get_next_picking_to_pack(self, batch) -> Picking: + """ + Return a picking not yet packed. + + The returned picking is the first + one into the list of picking not yet packed (is_shopfloor_packing_todo=True). + nbr_packages + """ + pickings_to_pack = batch.picking_ids.filtered( + lambda p: p.is_shopfloor_packing_todo + ) + move_lines = pickings_to_pack.mapped("move_line_ids") + move_lines = move_lines.filtered( + lambda ml: ml.result_package_id.is_internal + ).sorted(key=lambda ml: ml.result_package_id.name) + return fields.first(move_lines).picking_id + + def _get_move_lines_to_pack(self, picking) -> StockMoveLine: + """ + This returns the lines that have an internal package + and the same destination + """ + move_lines = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id.is_internal + ).sorted(key=lambda ml: ml.result_package_id.name) + # Take the first line to filter then the lines per destination + first_line = fields.first(move_lines) + return move_lines.filtered( + lambda line: line.location_dest_id == first_line.location_dest_id + ) + + def _prepare_pack_picking(self, batch, message=None) -> dict: + picking = self._get_next_picking_to_pack(batch) + move_lines = self._get_move_lines_to_pack(picking) + if not picking: + return self._response_put_in_pack( + batch.id, + message=self.msg_store.stock_picking_packed_successfully(picking), + ) + if picking.is_shopfloor_packing_pack_to_scan(): + return self._response_pack_picking_scan_pack(picking, message=message) + if self.default_pick_pack_action == "nbr_packages": + return self._response_pack_picking_put_in_pack(picking, message=message) + else: + return self._response_for_select_package( + picking, move_lines, message=message + ) + + def _postprocess_put_in_pack(self, picking, pack): + """Override this method to include post-processing logic for the new package, + such as printing..""" + return + + def _put_in_pack( + self, picking, move_lines, number_of_parcels=None, package_type_id=None + ) -> QuantPackage: + """ + This will enhance the put in pack flow by adding the number + of parcels or the package type to the generated package. + """ + pack = picking._put_in_pack(move_lines) + if ( + isinstance(pack, dict) + and pack.get("res_model") == "stock.quant.package" + and pack.get("res_id") + ): + pack = self.env["stock.quant.package"].browse(pack.get("res_id")) + if isinstance(pack, self.env["stock.quant.package"].__class__): + # Enhance package details either with number of packages or package_type + if number_of_parcels: + pack.number_of_parcels = number_of_parcels + elif package_type_id: + pack.package_type_id = self.env["stock.package.type"].browse( + package_type_id + ) + return pack + + def _data_for_packing_info(self, picking): + """Return the packing information + + Intended to be extended. + """ + # TODO: This could be avoided if included in the picking parser. + return "" + + def _data_for_delivery_package_type(self, packaging, **kw): + return self.data.delivery_packaging_list(packaging, **kw) + + def _check_allowed_qty_done(self, picking, lines) -> dict: + for line in lines: + # Do not allow to proceed if the qty_done of + # any of the selected lines + # is higher than the quantity to do. + if line.qty_done > line.reserved_uom_qty: + return self._response_for_select_package( + picking, + lines, + message=self.msg_store.selected_lines_qty_done_higher_than_allowed( + line + ), + ) + + # RESPONSES + + def _response_pack_picking_put_in_pack(self, picking, message=None) -> dict: + data = self.data.pack_picking(picking) + return self._response( + next_state="pack_picking_put_in_pack", data=data, message=message + ) + + def _response_pack_picking_scan_pack(self, picking, message=None) -> dict: + data = self.data.pack_picking(picking) + return self._response( + next_state="pack_picking_scan_pack", data=data, message=message + ) + + def _response_for_select_package(self, picking, lines, message=None) -> dict: + return self._response( + next_state="select_package", + data=self.data.select_package(picking, lines), + message=message, + ) + + def _response_for_select_dest_package(self, picking, message=None) -> dict: + packages = picking.mapped("move_line_ids.result_package_id").filtered( + "package_type_id" + ) + if not packages: + # FIXME: do we want to move from 'select_dest_package' to + # 'select_package' state? Until now (before enforcing the use of + # delivery package) this part of code was never reached as we + # always had a package on the picking (source or result) + # Also the response validator did not support this state... + return self._response_for_select_package( + picking, + message=self.msg_store.no_valid_package_to_select(), + ) + picking_data = self.data.picking(picking) + packages_data = self.data.packages( + packages.with_context(picking_id=picking.id).sorted(), + picking=picking, + with_packaging=True, + with_package_move_line_count=True, + ) + return self._response( + next_state="select_dest_package", + data={ + "picking": picking_data, + "packages": packages_data, + # "selected_move_lines": self._data_for_move_lines(move_lines.sorted()), + }, + message=message, + ) + + def _response_for_select_delivery_package_type( + self, picking, packaging, selected_lines, message=None + ) -> dict: + return self._response( + next_state="select_delivery_packaging", + data={ + "picking": self.data.picking(picking), + "selected_lines_for_packing": self.data.move_lines(selected_lines), + "packaging": self._data_for_delivery_package_type(packaging), + }, + message=message, + ) + + def _response_put_in_pack(self, picking_batch_id, message=None) -> dict: + """ + Fallback to prepare_unload + """ + res = self.prepare_unload(picking_batch_id) + if message: + res["message"] = message + return res + + +class ShopfloorClusterPickingValidator(Component): + """Validators for the Cluster Picking endpoints.""" + + _inherit = "shopfloor.cluster_picking.validator" + + def put_in_pack(self) -> dict: + return { + "picking_batch_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, + "nbr_packages": {"coerce": to_int, "required": False, "type": "integer"}, + "package_type_id": {"coerce": to_int, "required": False, "type": "integer"}, + } + + def scan_packing_to_pack(self) -> dict: + return { + "picking_batch_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def list_delivery_package_types(self) -> dict: + return { + "picking_batch_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, + } + + def scan_package_action(self) -> dict: + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_ids": { + "type": "list", + "required": True, + "schema": {"coerce": to_int, "required": True, "type": "integer"}, + }, + "barcode": {"required": True, "type": "string"}, + } + + +class ShopfloorClusterPickingValidatorResponse(Component): + """Validators for the Cluster Picking endpoints responses.""" + + _inherit = "shopfloor.cluster_picking.validator.response" + + def _states(self) -> dict: + states = super()._states() + states["pack_picking_put_in_pack"] = self.schemas.pack_picking() + states["pack_picking_scan_pack"] = self.schemas.pack_picking() + states["select_package"] = self.schemas.select_package() + states["select_delivery_packaging"] = self._schema_select_delivery_packaging + return states + + @property + def _schema_pack_picking(self) -> dict: + schema = self.schemas.pack_picking() + return {"type": "dict", "nullable": True, "schema": schema} + + @property + def _schema_select_package(self) -> dict: + schema = self.schemas.select_package() + return {"type": "dict", "nullable": True, "schema": schema} + + def prepare_unload(self) -> dict: + res = super().prepare_unload() + res["data"]["schema"]["pack_picking_put_in_pack"] = self._schema_pack_picking + res["data"]["schema"]["pack_picking_scan_pack"] = self._schema_pack_picking + res["data"]["schema"]["select_package"] = self._schema_select_package + return res + + def put_in_pack(self) -> dict: + return self.prepare_unload() + + def confirm_start(self) -> dict: + res = super().confirm_start() + res["data"]["schema"]["pack_picking_put_in_pack"] = self._schema_pack_picking + res["data"]["schema"]["pack_picking_scan_pack"] = self._schema_pack_picking + res["data"]["schema"]["select_package"] = self._schema_select_package + return res + + def select_package(self) -> dict: + res = self._response_schema( + next_states={"select_delivery_packaging", "select_package"} + ) + res["data"]["schema"]["select_package"] = self._schema_select_package + return res + + def scan_destination_pack(self) -> dict: + res = super().scan_destination_pack() + res["data"]["schema"]["pack_picking_put_in_pack"] = self._schema_pack_picking + res["data"]["schema"]["pack_picking_scan_pack"] = self._schema_pack_picking + return res + + def scan_packing_to_pack(self) -> dict: + return self._response_schema( + next_states={ + "unload_all", + "unload_single", + "pack_picking_put_in_pack", + "pack_picking_scan_pack", + "select_package", + } + ) + + def list_delivery_package_types(self) -> dict: + return self._response_schema( + next_states={"select_delivery_packaging", "select_package"} + ) + + @property + def _schema_select_delivery_packaging(self) -> dict: + return { + "picking": {"type": "dict", "schema": self.schemas.picking()}, + "selected_lines_for_packing": self.schemas._schema_list_of( + self.schemas.move_line() + ), + "packaging": self.schemas._schema_list_of( + self.schemas.delivery_packaging() + ), + } + + def scan_package_action(self) -> dict: + return self.prepare_unload() diff --git a/shopfloor_packing/services/packing.py b/shopfloor_packing/services/packing.py new file mode 100644 index 00000000000..66748872d29 --- /dev/null +++ b/shopfloor_packing/services/packing.py @@ -0,0 +1,78 @@ +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) +# Copyright 2024 ACSONE SA/NV (https://www.acsone.eu) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +from odoo.addons.component.core import Component +from odoo.addons.stock.models.stock_picking import Picking + + +class PackingAction(Component): + + _name = "shopfloor.packing.action" + _inherit = "shopfloor.process.action" + _usage = "packing" + _description = "This is the service to put products in pack" + + def _check_put_in_pack( + self, + record, + picking: Picking, + response_error_func: callable, + nbr_packages: int = None, + package_type_id: int = None, + ): + """ + This will check if parameters are correct and return a response with + the appropriate message. + """ + if not picking: + return response_error_func( + record, + message=self.msg_store.stock_picking_not_found(), + ) + if not picking.is_shopfloor_packing_todo: + return response_error_func( + record, + message=self.msg_store.stock_picking_already_packed(picking), + ) + if isinstance(nbr_packages, int) and nbr_packages <= 0: + return response_error_func( + record, + message=self.msg_store.nbr_packages_must_be_greated_than_zero(), + ) + # Check if package type exists + if package_type_id and not nbr_packages: + package_type = ( + self.env["stock.package.type"].browse(package_type_id).exists() + ) + if not package_type: + return response_error_func( + record, + message=self.msg_store.record_not_found(), + ) + return False + + def _scan_package_find(self, picking, barcode, search_types=None): + search = self._actions_for("search") + search_types = ( + "package", + "product", + "packaging", + "lot", + "serial", + "delivery_packaging", + ) + return search.find( + barcode, + types=search_types, + handler_kw=dict( + lot=dict(products=picking.move_ids.product_id), + serial=dict(products=picking.move_ids.product_id), + ), + ) + + def _check_scan_package_find(self, picking, search_result): + # Used by inheriting modules + return False diff --git a/shopfloor_packing/static/description/index.html b/shopfloor_packing/static/description/index.html new file mode 100644 index 00000000000..c23b1f97719 --- /dev/null +++ b/shopfloor_packing/static/description/index.html @@ -0,0 +1,438 @@ + + + + + +Shopfloor Packing + + + +
+

Shopfloor Packing

+ + +

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

+

In a two-step delivery process (or more), packing is a major task. +It is essential that the picking scenario helps operators to put products from +the same order into packages, when collecting multiple orders at the same time.

+

This module adds a new configuration option to the “cluster picking” scenario, +enabling the packing of pickings into packages. +When this option is activated, the operator will be prompted to pack the +products of the same picking. +In the background, a package will be created and set as the destination for +the stock move lines.

+

Table of contents

+ +
+

Usage

+

Go to the “cluster picking” scenario and check “Pack pickings”.

+
+
+

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

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

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.

+

This module is part of the OCA/wms project on GitHub.

+

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

+
+
+
+ + diff --git a/shopfloor_packing/tests/__init__.py b/shopfloor_packing/tests/__init__.py new file mode 100644 index 00000000000..b3db59427f7 --- /dev/null +++ b/shopfloor_packing/tests/__init__.py @@ -0,0 +1,5 @@ +from . import ( + test_cluster_picking_pack_picking, + test_cluster_picking_pick_pack, + test_cluster_picking_unload, +) diff --git a/shopfloor_packing/tests/common.py b/shopfloor_packing/tests/common.py new file mode 100644 index 00000000000..0655d670229 --- /dev/null +++ b/shopfloor_packing/tests/common.py @@ -0,0 +1,16 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.shopfloor.tests.test_cluster_picking_unload import ( + ClusterPickingUnloadingCommonCase, +) + + +# pylint: disable=missing-return +class ClusterPickingUnloadPackingCommonCase(ClusterPickingUnloadingCommonCase): + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.bin1.write({"name": "bin1", "is_internal": True}) + cls.bin2.write({"name": "bin2", "is_internal": True}) + cls.menu.sudo().pack_pickings = True diff --git a/shopfloor_packing/tests/test_cluster_picking_pack_picking.py b/shopfloor_packing/tests/test_cluster_picking_pack_picking.py new file mode 100644 index 00000000000..49ee460c398 --- /dev/null +++ b/shopfloor_packing/tests/test_cluster_picking_pack_picking.py @@ -0,0 +1,480 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from .common import ClusterPickingUnloadPackingCommonCase + + +class TestClusterPickingPrepareUnload(ClusterPickingUnloadPackingCommonCase): + def test_scan_destination_pack_bin_not_internal(self): + """Scan a destination package that is not an internal package.""" + self.bin2.is_internal = False + move_line = self.move_lines[0] + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": move_line.id, + # this bin is used for the other picking + "barcode": self.bin2.name, + "quantity": move_line.reserved_qty, + }, + ) + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(move_line), + message=self.service.msg_store.bin_should_be_internal(self.bin2), + ) + + def test_prepare_unload_all_same_dest(self): + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[:1], self.bin2) + self._set_dest_package_and_done(move_lines[1:], self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + location = self.packing_location + # The first bin to process is bin1 we should therefore scan the bin 1 + # to pack and put in pack + picking = move_lines[-1].picking_id + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_put_in_pack", + data=data, + ) + # we process to the put in pack + response = self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + "nbr_packages": 4, + }, + ) + message = self.service.msg_store.stock_picking_packed_successfully(picking) + result_package = picking.move_line_ids.mapped("result_package_id") + self.assertEqual(len(result_package), 1) + self.assertEqual(result_package[0].number_of_parcels, 4) + + picking = move_lines[0].picking_id + data = self.data.pack_picking(picking) + # message = self.service.msg_store.stock_picking_packed_successfully(picking) + self.assert_response( + response, next_state="pack_picking_scan_pack", data=data, message=message + ) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin2.name, + }, + ) + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_put_in_pack", + data=data, + ) + # we process to the put in pack + response = self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": move_lines.ids, + "nbr_packages": 2, + }, + ) + data = self._data_for_batch(self.batch, location) + message = self.service.msg_store.stock_picking_packed_successfully(picking) + self.assert_response( + response, next_state="unload_all", data=data, message=message + ) + + result_package = picking.move_line_ids.mapped("result_package_id") + self.assertEqual(len(result_package), 1) + self.assertEqual(result_package[0].number_of_parcels, 2) + + def test_prepare_unload_different_dest(self): + """All move lines have different destination locations.""" + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[:1], self.bin2) + self._set_dest_package_and_done(move_lines[1:], self.bin1) + move_lines[:1].write({"location_dest_id": self.packing_a_location.id}) + move_lines[1:].write({"location_dest_id": self.packing_b_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + # The first bin to process is bin1 we should therefore a pack_picking + # step with the picking info of the last move_line + picking = move_lines[-1].picking_id + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_put_in_pack", + data=data, + ) + # we process to the put in pack + response = self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + "nbr_packages": 4, + }, + ) + + message = self.service.msg_store.stock_picking_packed_successfully(picking) + + # next picking.. + picking = move_lines[0].picking_id + data = self.data.pack_picking(picking) + self.assert_response( + response, next_state="pack_picking_scan_pack", data=data, message=message + ) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin2.name, + }, + ) + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_put_in_pack", + data=data, + ) + # we process to the put in pack + response = self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + "nbr_packages": 2, + }, + ) + # Since the last move_line has been put in pack first, the first pack + # to unload is the one from the last move_line + new_bin = move_lines[-1].result_package_id + location = move_lines[-1].location_dest_id + data = self._data_for_batch(self.batch, location, pack=new_bin) + message = self.service.msg_store.stock_picking_packed_successfully(picking) + self.assert_response( + response, next_state="unload_single", data=data, message=message + ) + + def test_prepare_full_bin_unload(self): + # process one move_line and call unload + # the unload should return a pack_picking state + # and once processed continue with next move_lines + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[0], self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + # step with the picking info of the last move_line + picking = move_lines[0].picking_id + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + # we scan the pack and process to the put in pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_put_in_pack", + data=data, + ) + response = self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + "nbr_packages": 4, + }, + ) + result_package = picking.move_line_ids.mapped("result_package_id") + self.assertEqual(len(result_package), 1) + self.assertEqual(result_package[0].number_of_parcels, 4) + + # now we must unload + location = move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location) + self.assert_response( + response, + next_state="unload_all", + data=data, + message=self.service.msg_store.stock_picking_packed_successfully(picking), + ) + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + + # once the unload is done, we must process the others move_lines + move_line = self.service._next_line_for_pick(self.batch) + while move_line: + picking = move_line.picking_id + self.assertEqual(response["next_state"], "start_line") + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": move_line.id, + "barcode": self.bin1.name, + "quantity": move_line.reserved_uom_qty, + }, + ) + move_line = self.service._next_line_for_pick(self.batch) + + # everything is processed, we should put in pack... + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + # we scan the pack and process to the put in pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + "barcode": self.bin1.name, + }, + ) + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_put_in_pack", + data=data, + ) + response = self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + "nbr_packages": 2, + }, + ) + data = self._data_for_batch(self.batch, location) + self.assert_response( + response, + next_state="unload_all", + data=data, + message=self.service.msg_store.stock_picking_packed_successfully(picking), + ) + + result_package = picking.move_line_ids.mapped("result_package_id") + self.assertEqual(len(result_package), 1) + self.assertEqual(result_package[0].number_of_parcels, 2) + + def test_response_for_scan_destination(self): + """Check that non internal package are not proposed as package_dest.""" + line1 = self.two_lines_picking.move_line_ids[0] + # we already scan and put the first line in bin1 + self._set_dest_package_and_done(line1, self.bin1) + self.bin1.is_internal = False + self.assertFalse(self.service._last_picked_line(line1.picking_id)) + response = self.service._response_for_scan_destination(line1) + self.assertFalse(response["data"]["scan_destination"]["package_dest"]) + + def test_put_in_pack_partial_qty(self): + """ + Special case. + + If a partially available product becomes completely available and the reserved + quantity increases after the "put in pack" action, a new move line will be + created with the done quantity as a copy of the line that will be packed. + The original line will keep the result package, which is the package scanned + by the operator, and the remaining quantity to do. + + Since the package is an internal package, the line with the remaining quantity + to do will be proposed in the packing process and considered as picked. If the + operator don't pay attention, and confirm the packaging, both lines will be set + to done. + Test: + - The new line is correctly packed. + - The original line has no result package. + - After the "put in pack" of the partial quantity, the scenario returns + the unload step. + """ + move_line = self.move_lines[0] + picking = move_line.picking_id + self.assertEqual(len(picking.move_line_ids), 1) + move_line.reserved_uom_qty = 5 + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": move_line.id, + "barcode": self.bin1.name, + # collect half the demanded qty + "quantity": 5, + }, + ) + move_line.reserved_uom_qty = 10 + self.assertEqual(response["next_state"], "start_line") + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": move_line.picking_id.id, + "barcode": self.bin1.name, + }, + ) + self.assertEqual(response["next_state"], "pack_picking_put_in_pack") + response = self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + "nbr_packages": 2, + }, + ) + # after the put in pack, a new line is created with the remaining qty to do + self.assertEqual(len(picking.move_line_ids), 2) + self.assertFalse(move_line.result_package_id) + self.assertTrue((picking.move_line_ids - move_line).result_package_id) + self.assertFalse( + (picking.move_line_ids - move_line).result_package_id.is_internal + ) + self.assertEqual(response["next_state"], "unload_all") + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": move_line.picking_id.id, + "barcode": self.bin1.name, + }, + ) + self.assertEqual(response["next_state"], "unload_all") + + def _product_put_in_pack(self): + batch = self._create_picking_batch( + [[self.BatchProduct(product=self.product_a, quantity=10)]] + ) + move_line = batch.move_line_ids + self._set_dest_package_and_done(move_line, self.bin1) + move_line.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": batch.id} + ) + + # The first bin to process is bin1 scan the pack and try to put in pack + picking = move_line.picking_id + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_put_in_pack", + data=data, + ) + # we process to the put in pack + self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": batch.id, + "picking_id": picking.id, + "selected_line_ids": move_line.ids, + "nbr_packages": 4, + }, + ) + return move_line + + def test_put_in_pack_set_correct_package_type(self): + """Shopfloor should set the package type if possible.""" + pt_model = self.env["stock.package.type"].sudo() + package_type_4 = pt_model.create({"name": "PT4", "number_of_parcels": 4}) + package_type_7 = pt_model.create({"name": "PT7", "number_of_parcels": 7}) + self.product_a.package_type_id = package_type_7 + move_line = self._product_put_in_pack() + self.assertEqual(move_line.result_package_id.number_of_parcels, 4) + self.assertEqual(move_line.result_package_id.package_type_id, package_type_4) + move_line.picking_id._action_done() + self.assertEqual(move_line.result_package_id.number_of_parcels, 4) + self.assertEqual(move_line.result_package_id.package_type_id, package_type_4) + + def test_put_in_pack_cant_set_correct_package_type(self): + """If shopfloor can't find a package type, storage_type shouldn't overwrite + number_of_parcels.""" + pt_model = self.env["stock.package.type"].sudo() + package_type_7 = pt_model.create({"name": "PT7", "number_of_parcels": 7}) + self.product_a.package_type_id = package_type_7 + move_line = self._product_put_in_pack() + self.assertEqual(move_line.result_package_id.number_of_parcels, 4) + self.assertFalse(move_line.result_package_id.package_type_id) + move_line.picking_id._action_done() + self.assertEqual(move_line.result_package_id.number_of_parcels, 4) + self.assertFalse(move_line.result_package_id.package_type_id) diff --git a/shopfloor_packing/tests/test_cluster_picking_pick_pack.py b/shopfloor_packing/tests/test_cluster_picking_pick_pack.py new file mode 100644 index 00000000000..eb4baa2622c --- /dev/null +++ b/shopfloor_packing/tests/test_cluster_picking_pick_pack.py @@ -0,0 +1,557 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.fields import first + +from .common import ClusterPickingUnloadPackingCommonCase + + +class TestClusterPickingPrepareUnload(ClusterPickingUnloadPackingCommonCase): + def _create_package_type(self): + self.carrier_product = ( + self.env["product.product"] + .sudo() + .create( + { + "name": "Test Product", + "type": "service", + } + ) + ) + self.carrier = ( + self.env["delivery.carrier"] + .sudo() + .create( + { + "name": "Test Carrier", + "product_id": self.carrier_product.id, + } + ) + ) + self.package_type = ( + self.env["stock.package.type"] + .sudo() + .create( + { + "name": "BOX-5", + "package_carrier_type": "none", + "number_of_parcels": 5.0, + "barcode": "BOX-5", + } + ) + ) + self.package_types = self.env["stock.package.type"].search( + [("package_carrier_type", "=", "none")], order="number_of_parcels,name" + ) + + def test_prepare_unload_all_same_dest_with_dest_package(self): + """ + Activate the behavior that allows to pack at the pick step (cluster) + Activate the behavior that change the default action -> Scan the package type + At the unload step, ask to select a delivery package (from types) + """ + self.menu.sudo().write( + { + "pick_pack_same_time": True, + "default_pack_pickings_action": "package_type", + } + ) + + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[:1], self.bin2) + self._set_dest_package_and_done(move_lines[1:], self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + location = self.packing_location + # The first bin to process is bin1 we should therefore scan the bin 1 + # to pack and put in pack + picking = move_lines[-1].picking_id + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + lines = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id.is_internal + ).sorted(key=lambda ml: ml.result_package_id.name) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + + data = self.data.select_package(picking, lines) + self.assert_response( + response, + next_state="select_package", + data=data, + ) + # we process to the put in pack + response = self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + "nbr_packages": 4, + }, + ) + message = self.service.msg_store.stock_picking_packed_successfully(picking) + result_package = picking.move_line_ids.mapped("result_package_id") + self.assertEqual(len(result_package), 1) + self.assertEqual(result_package[0].number_of_parcels, 4) + + picking = move_lines[0].picking_id + data = self.data.pack_picking(picking) + # message = self.service.msg_store.stock_picking_packed_successfully(picking) + self.assert_response( + response, next_state="pack_picking_scan_pack", data=data, message=message + ) + lines = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id.is_internal + ).sorted(key=lambda ml: ml.result_package_id.name) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin2.name, + }, + ) + data = self.data.select_package(picking, lines) + self.assert_response( + response, + next_state="select_package", + data=data, + ) + # we process to the put in pack + response = self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + "nbr_packages": 2, + }, + ) + data = self._data_for_batch(self.batch, location) + message = self.service.msg_store.stock_picking_packed_successfully(picking) + self.assert_response( + response, next_state="unload_all", data=data, message=message + ) + + result_package = picking.move_line_ids.mapped("result_package_id") + self.assertEqual(len(result_package), 1) + self.assertEqual(result_package[0].number_of_parcels, 2) + + def test_pack_no_package_type(self): + """ + Activate the behavior that allows to pack at the pick step (cluster) + Activate the behavior that change the default action -> Scan the package type + At the unload step, ask to select a delivery package (from types) + """ + self.menu.sudo().write( + { + "pick_pack_same_time": True, + "default_pack_pickings_action": "package_type", + } + ) + + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[:1], self.bin2) + self._set_dest_package_and_done(move_lines[1:], self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + # The first bin to process is bin1 we should therefore scan the bin 1 + # to pack and put in pack + picking = move_lines[-1].picking_id + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + lines = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id.is_internal + ).sorted(key=lambda ml: ml.result_package_id.name) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + + data = self.data.select_package(picking, lines) + self.assert_response( + response, + next_state="select_package", + data=data, + ) + # We use new pack + response = self.service.dispatch( + "list_delivery_package_types", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": lines.ids, + }, + ) + message = { + "message_type": "warning", + "body": "No delivery package type available.", + } + data = self.data.select_package(picking, lines) + self.assert_response( + response, next_state="select_package", data=data, message=message + ) + + def test_list_delivery_package_picking_done(self): + """ """ + self.menu.sudo().write( + { + "pick_pack_same_time": True, + "default_pack_pickings_action": "package_type", + } + ) + + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[:1], self.bin2) + self._set_dest_package_and_done(move_lines[1:], self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + # The first bin to process is bin1 we should therefore scan the bin 1 + # to pack and put in pack + picking = move_lines[-1].picking_id + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + lines = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id.is_internal + ).sorted(key=lambda ml: ml.result_package_id.name) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + + data = self.data.select_package(picking, lines) + self.assert_response( + response, + next_state="select_package", + data=data, + ) + + picking._action_done() + # Delivery is already done + response = self.service.dispatch( + "list_delivery_package_types", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": lines.ids, + }, + ) + message = {"message_type": "info", "body": "Operation already processed."} + data = {} + self.assert_response(response, next_state="start", data=data, message=message) + + def test_list_delivery_package_picking_qty_superior(self): + self._create_package_type() + self.menu.sudo().write( + { + "pick_pack_same_time": True, + "default_pack_pickings_action": "package_type", + } + ) + + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[:1], self.bin2) + self._set_dest_package_and_done(move_lines[1:], self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + # The first bin to process is bin1 we should therefore scan the bin 1 + # to pack and put in pack + picking = move_lines[-1].picking_id + picking.carrier_id = self.carrier + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + lines = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id.is_internal + ).sorted(key=lambda ml: ml.result_package_id.name) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + + data = self.data.select_package(picking, lines) + self.assert_response( + response, + next_state="select_package", + data=data, + ) + line = first(picking.move_line_ids) + line.qty_done = line.reserved_qty + 1 + # Delivery is already done + response = self.service.dispatch( + "list_delivery_package_types", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": lines.ids, + }, + ) + message = { + "message_type": "warning", + "body": "The quantity scanned for one or more lines " + "cannot be higher than the maximum allowed. (%s : %s > %s)" + % (line.product_id.name, line.qty_done, line.reserved_qty), + } + data = self.data.select_package(picking, lines.sorted()) + # data = self.data.pack_picking(next_picking) + self.assert_response( + response, next_state="select_package", data=data, message=message + ) + + def test_pack_package_type(self): + self._create_package_type() + self.menu.sudo().write( + { + "pick_pack_same_time": True, + "default_pack_pickings_action": "package_type", + } + ) + + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[:1], self.bin2) + self._set_dest_package_and_done(move_lines[1:], self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + # The first bin to process is bin1 we should therefore scan the bin 1 + # to pack and put in pack + picking = move_lines[-1].picking_id + picking.carrier_id = self.carrier + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + lines = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id.is_internal + ).sorted(key=lambda ml: ml.result_package_id.name) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + + data = self.data.select_package(picking, lines) + self.assert_response( + response, + next_state="select_package", + data=data, + ) + # We use new pack + response = self.service.dispatch( + "list_delivery_package_types", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": lines.ids, + }, + ) + data = {} + data["selected_lines_for_packing"] = self.data.move_lines(lines) + data["packaging"] = self.data.delivery_packaging_list(self.package_types) + data["picking"] = self.data.picking(picking) + self.assert_response( + response, + next_state="select_delivery_packaging", + data=data, + ) + + response = self.service.dispatch( + "put_in_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + "package_type_id": self.package_type.id, + }, + ) + message = self.service.msg_store.stock_picking_packed_successfully(picking) + next_picking = self.batch.picking_ids.filtered( + lambda p: p.is_shopfloor_packing_todo + ) + data = data = self.data.pack_picking(next_picking) + + self.assert_response( + response, next_state="pack_picking_scan_pack", data=data, message=message + ) + + def test_pack_package_type_scan(self): + self._create_package_type() + self.menu.sudo().write( + { + "pick_pack_same_time": True, + "default_pack_pickings_action": "package_type", + } + ) + + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[:1], self.bin2) + self._set_dest_package_and_done(move_lines[1:], self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + # The first bin to process is bin1 we should therefore scan the bin 1 + # to pack and put in pack + picking = move_lines[-1].picking_id + picking.carrier_id = self.carrier + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + lines = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id.is_internal + ).sorted(key=lambda ml: ml.result_package_id.name) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + + data = self.data.select_package(picking, lines) + self.assert_response( + response, + next_state="select_package", + data=data, + ) + + # we scan the package type + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": lines.ids, + "barcode": "BOX-5", + }, + ) + message = self.service.msg_store.stock_picking_packed_successfully(picking) + next_picking = first( + self.batch.picking_ids.filtered(lambda p: p.is_shopfloor_packing_todo) + ) + data = self.data.pack_picking(next_picking) + self.assert_response( + response, next_state="pack_picking_scan_pack", message=message, data=data + ) + + def test_pack_package_type_no_picking(self): + self._create_package_type() + self.menu.sudo().write( + { + "pick_pack_same_time": True, + "default_pack_pickings_action": "package_type", + } + ) + + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[:1], self.bin2) + self._set_dest_package_and_done(move_lines[1:], self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + # The first bin to process is bin1 we should therefore scan the bin 1 + # to pack and put in pack + picking = move_lines[-1].picking_id + picking.carrier_id = self.carrier + data = self.data.pack_picking(picking) + self.assert_response( + response, + next_state="pack_picking_scan_pack", + data=data, + ) + lines = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id.is_internal + ).sorted(key=lambda ml: ml.result_package_id.name) + # we scan the pack + response = self.service.dispatch( + "scan_packing_to_pack", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "barcode": self.bin1.name, + }, + ) + + data = self.data.select_package(picking, lines) + self.assert_response( + response, + next_state="select_package", + data=data, + ) + + # Another action validated the picking + picking._action_done() + + # We use new pack + response = self.service.dispatch( + "list_delivery_package_types", + params={ + "picking_batch_id": self.batch.id, + "picking_id": picking.id, + "selected_line_ids": lines.ids, + }, + ) + data = {} + data["packaging"] = self.data.delivery_packaging_list(self.package_types) + data["picking"] = self.data.picking(picking) + message = {"message_type": "info", "body": "Operation already processed."} + data = {} + self.assert_response(response, next_state="start", data=data, message=message) diff --git a/shopfloor_packing/tests/test_cluster_picking_unload.py b/shopfloor_packing/tests/test_cluster_picking_unload.py new file mode 100644 index 00000000000..e81895089bb --- /dev/null +++ b/shopfloor_packing/tests/test_cluster_picking_unload.py @@ -0,0 +1,50 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.shopfloor.tests.test_cluster_picking_unload import ( + ClusterPickingPrepareUnloadCase, + ClusterPickingSetDestinationAllCase, + ClusterPickingUnloadScanDestinationCase, + ClusterPickingUnloadScanPackCase, + ClusterPickingUnloadSplitCase, +) + + +class ClusterPickingPrepareUnloadCase2(ClusterPickingPrepareUnloadCase): + """ + Ensure that the normal unload process is preserved if the pack_pickings option. + + is not activated on the menu (default) + """ + + +class ClusterPickingSetDestinationAllCase2(ClusterPickingSetDestinationAllCase): + """ + Ensure that the normal unload process is preserved if the pack_pickings option. + + is not activated on the menu (default) + """ + + +class ClusterPickingUnloadSplitCase2(ClusterPickingUnloadSplitCase): + """ + Ensure that the normal unload process is preserved if the pack_pickings option. + + is not activated on the menu (default) + """ + + +class ClusterPickingUnloadScanPackCase2(ClusterPickingUnloadScanPackCase): + """ + Ensure that the normal unload process is preserved if the pack_pickings option. + + is not activated on the menu (default) + """ + + +class ClusterPickingUnloadScanDestinationCase2(ClusterPickingUnloadScanDestinationCase): + """ + Ensure that the normal unload process is preserved if the pack_pickings option. + + is not activated on the menu (default) + """ diff --git a/shopfloor_packing/views/shopfloor_menu.xml b/shopfloor_packing/views/shopfloor_menu.xml new file mode 100644 index 00000000000..d251ba72d14 --- /dev/null +++ b/shopfloor_packing/views/shopfloor_menu.xml @@ -0,0 +1,24 @@ + + + + + + shopfloor.menu + + + + + + + + + + + + + diff --git a/shopfloor_packing/views/stock_picking.xml b/shopfloor_packing/views/stock_picking.xml new file mode 100644 index 00000000000..49e153660da --- /dev/null +++ b/shopfloor_packing/views/stock_picking.xml @@ -0,0 +1,18 @@ + + + + + + stock.picking + + + + + + + + + + +