diff --git a/setup/shopfloor/odoo/addons/shopfloor b/setup/shopfloor/odoo/addons/shopfloor new file mode 120000 index 0000000000..bfab73c253 --- /dev/null +++ b/setup/shopfloor/odoo/addons/shopfloor @@ -0,0 +1 @@ +../../../../shopfloor \ No newline at end of file diff --git a/setup/shopfloor/setup.py b/setup/shopfloor/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopfloor/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopfloor/README.rst b/shopfloor/README.rst new file mode 100644 index 0000000000..c79dba03ed --- /dev/null +++ b/shopfloor/README.rst @@ -0,0 +1,160 @@ +========= +Shopfloor +========= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:42c11158623e057f27d7334544cb90bd673ec0ab238924094e090beacc478aef + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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 + :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 + :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 is a barcode scanner application for internal warehouse operations. + +The application supports scenarios, to relate to Operation Types: + +* Cluster Picking +* Zone Picking +* Checkout/Packing +* Delivery +* Location Content Transfer +* Single Pack Transfer + +This module provides REST APIs to support the scenarios. It needs a frontend +to consume the backend APIs and provide screens for users on barcode devices. +A default front-end application is provided by ``shopfloor_mobile``. + +| Note: if you want to enable a new scenario on an existing application, you must trigger the registry sync on the shopfloor.app in a post_init_hook or a post-migrate script. +| See an example `here `_. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +An API key is created in the Demo data (for development), using +the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DAC + +Curl example:: + + curl -X POST "http://localhost:8069/shopfloor/user/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" + +Known issues / Roadmap +====================== + +* improve documentation +* split out scenario components to their own modules +* maybe split common stock features to `shopfloor_stock_base` + and move scenario to `shopfloor_wms`? + +Changelog +========= + +13.0.1.0.0 +~~~~~~~~~~ + +First official version. + +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 +~~~~~~~ + +* Camptocamp +* BCIM +* Akretion + +Contributors +~~~~~~~~~~~~ + +* Guewen Baconnier +* Simone Orsi +* Sébastien Alix +* Alexandre Fayolle +* Benoit Guillot +* Thierry Ducrest +* Raphaël Reverdy +* Jacques-Etienne Baudoux +* Juan Miguel Sánchez Arce +* Michael Tietz (MT Software) +* Souheil Bejaoui +* Laurent Mignon + +Design +~~~~~~ + +* Joël Grand-Guillaume +* Jacques-Etienne Baudoux + +Other credits +~~~~~~~~~~~~~ + +**Financial support** + +* Cosanum +* Camptocamp R&D +* Akretion R&D + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-guewen| image:: https://github.com/guewen.png?size=40px + :target: https://github.com/guewen + :alt: guewen +.. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk +.. |maintainer-sebalix| image:: https://github.com/sebalix.png?size=40px + :target: https://github.com/sebalix + :alt: sebalix + +Current `maintainers `__: + +|maintainer-guewen| |maintainer-simahawk| |maintainer-sebalix| + +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/__init__.py b/shopfloor/__init__.py new file mode 100644 index 0000000000..436961449d --- /dev/null +++ b/shopfloor/__init__.py @@ -0,0 +1,4 @@ +from . import models +from . import actions +from . import components +from . import services diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py new file mode 100644 index 0000000000..12c91bb1a8 --- /dev/null +++ b/shopfloor/__manifest__.py @@ -0,0 +1,65 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# Copyright 2020 BCIM +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Shopfloor", + "summary": "manage warehouse operations with barcode scanners", + "version": "16.0.1.0.0", + "development_status": "Beta", + "category": "Inventory", + "website": "https://github.com/OCA/wms", + "author": "Camptocamp, BCIM, Akretion, Odoo Community Association (OCA)", + "maintainers": ["guewen", "simahawk", "sebalix"], + "license": "AGPL-3", + "application": True, + "depends": [ + "shopfloor_base", + "stock", + "stock_picking_batch", + "jsonifier", + "base_rest", + "base_sparse_field", + # OCA / stock-logistics-warehouse + "stock_helper", + "stock_picking_completion_info", + # OCA / stock-logistics-workflow + "stock_move_line_change_lot", + "stock_quant_package_dimension", + "stock_quant_package_product_packaging", + "stock_picking_progress", + # TODO: used for manuf info on prod detail. + # This must be an optional dep + "product_manufacturer", + # TODO: used for prod lot expire detail info. + # This must be an optional dep + "product_expiry", + # TODO: used for package.package_type_id detail info. + # This must be an optional dep + "stock_storage_type", + # TODO: used for picking.carrier_id detail info + # and to validate packaging/carrier in checkout scenario + # This must be an optional dep + "delivery", + # OCA / product-attribute + "product_packaging_level", + # OCA / delivery + "stock_picking_delivery_link", + ], + "data": [ + "data/shopfloor_scenario_data.xml", + "security/groups.xml", + "views/shopfloor_menu.xml", + "views/stock_picking_type.xml", + "views/stock_location.xml", + "views/stock_move_line.xml", + ], + "demo": [ + "demo/stock_picking_type_demo.xml", + "demo/shopfloor_profile_demo.xml", + "demo/shopfloor_menu_demo.xml", + "demo/shopfloor_app_demo.xml", + ], + "installable": True, +} diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py new file mode 100644 index 0000000000..5fecbe84ce --- /dev/null +++ b/shopfloor/actions/__init__.py @@ -0,0 +1,15 @@ +from . import change_package_lot +from . import data +from . import data_detail +from . import schema +from . import schema_detail +from . import completion_info +from . import location_content_transfer_sorter +from . import message +from . import search +from . import inventory +from . import savepoint +from . import move_line_search +from . import stock +from . import stock_unreserve +from . import packaging diff --git a/shopfloor/actions/change_package_lot.py b/shopfloor/actions/change_package_lot.py new file mode 100644 index 0000000000..9c02d98a4b --- /dev/null +++ b/shopfloor/actions/change_package_lot.py @@ -0,0 +1,164 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2024 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, exceptions +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare, float_is_zero + +from odoo.addons.component.core import Component + + +class InventoryError(UserError): + pass + + +class ChangePackageLot(Component): + """Provide methods for changing a package or a lot on a move line""" + + _name = "shopfloor.change.package.lot.action" + _inherit = "shopfloor.process.action" + _usage = "change.package.lot" + + def change_lot(self, move_line, lot, response_ok_func, response_error_func): + """Change the lot on the move line. + + :param response_ok_func: callable used to return ok response + :param response_error_func: callable used to return error response + """ + # If the lot is part of a package, what we really want + # is not to change the lot, but change the package (which will + # in turn change the lot altogether), but we have to pay attention + # to some things: + # * cannot replace a package by a lot without package (qty may be + # different, ...) + # * if we have several packages for the same lot, we can't know which + # one the operator is moving, ask to scan a package + lot_quants = self.env["stock.quant"].search( + [ + ("lot_id", "=", lot.id), + ("location_id", "=", move_line.location_id.id), + ("quantity", ">", 0), + ] + ) + package_quants = lot_quants.filtered(lambda quant: quant.package_id) + unit_quants = lot_quants - package_quants + + if len(package_quants) > 1 or (package_quants and unit_quants): + # When we can't know which package to take, ask to scan a package. + # If we have both units and package, they have to scan the package + # first. + return response_error_func( + move_line, + message=self.msg_store.several_packs_in_location(move_line.location_id), + ) + elif len(package_quants) == 1: + # change the package directly + package = package_quants.package_id + return self.change_package( + move_line, package, response_ok_func, response_error_func + ) + return self._change_pack_lot_change_lot( + move_line, lot, response_ok_func, response_error_func + ) + + def _change_pack_lot_change_lot( + self, move_line, lot, response_ok_func, response_error_func + ): + previous_lot = move_line.lot_id + previous_reserved_uom_qty = move_line.reserved_uom_qty + + inventory = self._actions_for("inventory") + + try: + with self.env.cr.savepoint(): + move_line.write( + { + "lot_id": lot.id, + "package_id": False, + "result_package_id": False, + } + ) + rounding = move_line.product_id.uom_id.rounding + if float_is_zero( + move_line.reserved_uom_qty, precision_rounding=rounding + ): + # The lot is not found at all, but the user scanned it, which means + # it's an error in the stock data! + raise InventoryError("Lot not available") + except InventoryError: + inventory.create_control_stock( + move_line.location_id, + move_line.product_id, + lot=lot, + name=_( + "Pick: stock issue on lot: %(lot_name)s found in %(location_name)s", + lot_name=lot.name, + location_name=move_line.location_id.name, + ), + ) + message = self.msg_store.cannot_change_lot_already_picked(lot) + return response_error_func(move_line, message=message) + except UserError as e: + message = { + "message_type": "error", + "body": str(e), + } + return response_error_func(move_line, message=message) + + message = self.msg_store.lot_replaced_by_lot(previous_lot, lot) + if ( + float_compare( + move_line.reserved_uom_qty, + previous_reserved_uom_qty, + precision_rounding=rounding, + ) + != 0 + ): + message["body"] += " " + _("The quantity to do has changed!") + return response_ok_func(move_line, message=message) + + def _package_content_replacement_allowed(self, package, move_line): + # we can't replace by a package which doesn't contain the product... + return move_line.product_id in package.quant_ids.product_id + + def change_package(self, move_line, package, response_ok_func, response_error_func): + # Prevent change if package is already set and it's the same + if move_line.package_id == package: + return response_error_func( + move_line, + message=self.msg_store.package_change_error_same_package(package), + ) + + # prevent to replace a package by a package that would not satisfy the + # move (different product) + content_replacement_allowed = self._package_content_replacement_allowed( + package, move_line + ) + if not content_replacement_allowed: + return response_error_func( + move_line, message=self.msg_store.package_different_content(package) + ) + + previous_package = move_line.package_id + + # /!\ be sure to box the side-effects before calling "replace_package" + # in the savepoint, as we catch the error, we must be sure that any + # change is rollbacked + try: + with self.env.cr.savepoint(): + # if no quantity is available in the package, this call will + # raise a UserError, which will revert the savepoint + move_line.replace_package(package) + except exceptions.UserError as err: + return response_error_func( + move_line, + message=self.msg_store.package_change_error(package, err.args[0]), + ) + + if previous_package: + message = self.msg_store.package_replaced_by_package( + previous_package, package + ) + else: + message = self.msg_store.units_replaced_by_package(package) + return response_ok_func(move_line, message=message) diff --git a/shopfloor/actions/completion_info.py b/shopfloor/actions/completion_info.py new file mode 100644 index 0000000000..5892e85073 --- /dev/null +++ b/shopfloor/actions/completion_info.py @@ -0,0 +1,42 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _ + +from odoo.addons.component.core import Component + + +class CompletionInfo(Component): + """Provide methods for completion info of pickings + + They are based on the module "stock_picking_completion_info" from + OCA/stock-logistics-warehouse. + """ + + _name = "shopfloor.completion.info.action" + _inherit = "shopfloor.process.action" + _usage = "completion.info" + + def popup(self, move_lines): + """Return a popup if move lines make chained pickings ready + + Return None in case no popup should be displayed. + """ + pickings = move_lines.mapped("picking_id").filtered( + lambda p: p.picking_type_id.display_completion_info + and p.completion_info == "next_picking_ready" + ) + if not pickings: + return None + next_pickings = pickings.mapped("move_ids.move_dest_ids.picking_id").filtered( + lambda p: p.state == "assigned" + ) + if not next_pickings: + return None + return { + "body": _( + "Last operation of transfer %(picking_names)s. " + "Next operation (%(next_picking_names)s) is ready to proceed.", + picking_names=", ".join(pickings.mapped("name")), + next_picking_names=", ".join(next_pickings.mapped("name")), + ) + } diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py new file mode 100644 index 0000000000..6cc7e25317 --- /dev/null +++ b/shopfloor/actions/data.py @@ -0,0 +1,329 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields + +from odoo.addons.component.core import Component +from odoo.addons.shopfloor_base.utils import ensure_model + + +class DataAction(Component): + _inherit = "shopfloor.data.action" + + @ensure_model("stock.location") + def location(self, record, **kw): + parser = self._location_parser + data = self._jsonify(record.with_context(location=record.id), parser, **kw) + if "with_operation_progress" in kw: + operation_progress = self._get_location_operations_progress(record) + data.update({"operation_progress": operation_progress}) + return data + + def locations(self, record, **kw): + return self.location(record, multi=True) + + def _get_location_operations_progress(self, location): + lines = self.env["stock.move.line"].search( + [ + ("location_id", "=", location.id), + ("state", "in", ["partially_available", "assigned"]), + ("picking_id.state", "=", "assigned"), + ] + ) + # operations_to_do = number of total operations that are pending for this location. + # operations_done = number of operations already done. + # A line with an assigned package counts as 1 operation. + operations_to_do = 0 + operations_done = 0 + for line in lines: + operations_done += line.qty_done if not line.package_id else 1 + operations_to_do += line.reserved_uom_qty if not line.package_id else 1 + return { + "done": operations_done, + "to_do": operations_to_do, + } + + @property + def _location_parser(self): + return [ + "id", + "name", + # Fallback to name if barcode is not valued. + ("barcode", lambda rec, fname: rec[fname] if rec[fname] else rec.name), + ] + + @ensure_model("stock.picking") + def picking(self, record, **kw): + parser = self._picking_parser + # progress is a heavy computed field, + # and it may reduce performance significatively + # when dealing with a large number of pickings. + # Thus, we make it optional. + if "with_progress" in kw: + parser.append("progress") + return self._jsonify(record, parser, **kw) + + def pickings(self, record, **kw): + return self.picking(record, multi=True) + + @property + def _picking_parser(self, **kw): + return [ + "id", + "name", + "origin", + "note", + ("partner_id:partner", self._partner_parser), + ("carrier_id:carrier", self._simple_record_parser()), + ("ship_carrier_id:ship_carrier", self._simple_record_parser()), + "move_line_count", + "package_level_count", + "bulk_line_count", + "total_weight:weight", + "scheduled_date", + ] + + @ensure_model("stock.quant.package") + def package(self, record, picking=None, with_packaging=False, **kw): + """Return data for a stock.quant.package + + If a picking is given, it will include the number of lines of the package + for the picking. + """ + parser = self._package_parser + if with_packaging: + parser += self._package_packaging_parser + data = self._jsonify(record, parser, **kw) + # handle special cases + if data and picking: + lines = picking.move_line_ids.filtered( + lambda l: l.result_package_id == record + and l.state in ["partially_available", "assigned", "done"] + ) + data.update({"move_line_count": len(lines)}) + return data + + def packages(self, records, picking=None, **kw): + return [self.package(rec, picking=picking, **kw) for rec in records] + + @property + def _package_parser(self): + return [ + "id", + "name", + "shopfloor_weight:weight", + ("package_type_id:storage_type", ["id", "name"]), + ] + + @property + def _package_packaging_parser(self): + return [ + ("product_packaging_id:packaging", self._packaging_parser), + ] + + @ensure_model("product.packaging") + def packaging(self, record, **kw): + return self._jsonify(record, self._packaging_parser, **kw) + + def packaging_list(self, record, **kw): + return self.packaging(record, multi=True) + + @property + def _packaging_parser(self): + return [ + "id", + ("packaging_level_id:name", lambda rec, fname: rec.packaging_level_id.name), + ("packaging_level_id:code", lambda rec, fname: rec.packaging_level_id.code), + "qty", + ] + + @ensure_model("stock.package.type") + def delivery_packaging(self, record, **kw): + return self._jsonify(record, self._delivery_packaging_parser, **kw) + + def delivery_packaging_list(self, records, **kw): + return self.delivery_packaging(records, multi=True) + + @property + def _delivery_packaging_parser(self): + return [ + "id", + "name", + "package_carrier_type:packaging_type", + "barcode", + ] + + @ensure_model("stock.lot") + def lot(self, record, **kw): + return self._jsonify(record, self._lot_parser, **kw) + + def lots(self, record, **kw): + return self.lot(record, multi=True) + + @property + def _lot_parser(self): + return self._simple_record_parser() + ["ref", "expiration_date"] + + @ensure_model("stock.move.line") + def move_line(self, record, with_picking=False, **kw): + record = record.with_context(location=record.location_id.id) + parser = self._move_line_parser + if with_picking: + parser += [("picking_id:picking", self._picking_parser)] + data = self._jsonify(record, parser) + if data: + data.update( + { + # cannot use sub-parser here + # because result might depend on picking + "package_src": self.package( + record.package_id, record.picking_id, **kw + ), + "package_dest": self.package( + record.result_package_id.with_context( + picking_id=record.picking_id.id + ), + record.picking_id, + **kw, + ), + } + ) + return data + + def move_lines(self, records, **kw): + return [self.move_line(rec, **kw) for rec in records] + + @property + def _move_line_parser(self): + return [ + "id", + "qty_done", + "reserved_uom_qty:quantity", + ("product_id:product", self._product_parser), + ("lot_id:lot", self._lot_parser), + ("location_id:location_src", self._location_parser), + ("location_dest_id:location_dest", self._location_parser), + ( + "move_id:priority", + lambda rec, fname: rec.move_id.priority or "", + ), + "progress", + ] + + @ensure_model("stock.move") + def move(self, record, **kw): + record = record.with_context(location=record.location_id.id) + parser = self._move_parser + return self._jsonify(record, parser) + + def moves(self, records, **kw): + return [self.move(rec, **kw) for rec in records] + + @property + def _move_parser(self): + return [ + "id", + "quantity_done", + "product_uom_qty:quantity", + ("product_id:product", self._product_parser), + ("location_id:location_src", self._location_parser), + ("location_dest_id:location_dest", self._location_parser), + "priority", + "progress", + ] + + @ensure_model("stock.package_level") + def package_level(self, record, **kw): + return self._jsonify(record, self._package_level_parser) + + def package_levels(self, records, **kw): + return [self.package_level(rec, **kw) for rec in records] + + @property + def _package_level_parser(self): + return [ + "id", + "is_done", + ("picking_id:picking", self._simple_record_parser()), + ("package_id:package_src", self._package_parser), + ("location_dest_id:location_dest", self._location_parser), + ( + "location_id:location_src", + lambda rec, fname: self.location( + fields.first(rec.move_line_ids).location_id + or fields.first(rec.move_lines).location_id + or rec.picking_id.location_id + ), + ), + # tnx to stock_quant_package_product_packaging + ( + "package_id:product", + lambda rec, fname: self.product(rec.package_id.single_product_id), + ), + # TODO: allow to pass mapped path to jsonifier + ( + "package_id:quantity", + lambda rec, fname: rec.package_id.single_product_qty, + ), + ] + + @ensure_model("product.product") + def product(self, record, **kw): + return self._jsonify(record, self._product_parser, **kw) + + def products(self, record, **kw): + return self.product(record, multi=True) + + @property + def _product_parser(self): + return [ + "id", + "name", + "display_name", + "default_code", + "barcode", + ("packaging_ids:packaging", self._product_packaging), + ("uom_id:uom", self._simple_record_parser() + ["factor", "rounding"]), + ("seller_ids:supplier_code", self._product_supplier_code), + ] + + def _product_packaging(self, rec, field): + return self._jsonify( + rec.packaging_ids.filtered(lambda x: x.qty), + self._packaging_parser, + multi=True, + ) + + def _product_supplier_code(self, rec, field): + supplier_info = fields.first( + rec.seller_ids.filtered(lambda x: x.product_id == rec) + ) + return supplier_info.product_code or "" + + @ensure_model("stock.picking.batch") + def picking_batch(self, record, with_pickings=False, **kw): + parser = self._picking_batch_parser + if with_pickings: + parser.append(("picking_ids:pickings", self._picking_parser)) + return self._jsonify(record, parser, **kw) + + def picking_batches(self, record, with_pickings=False, **kw): + return self.picking_batch(record, with_pickings=with_pickings, multi=True) + + @property + def _picking_batch_parser(self): + return ["id", "name", "picking_count", "move_line_count", "total_weight:weight"] + + @ensure_model("stock.picking.type") + def picking_type(self, record, **kw): + parser = self._picking_type_parser + return self._jsonify(record, parser, **kw) + + def picking_types(self, record, **kw): + return self.picking_type(record, multi=True) + + @property + def _picking_type_parser(self): + return [ + "id", + "name", + ] diff --git a/shopfloor/actions/data_detail.py b/shopfloor/actions/data_detail.py new file mode 100644 index 0000000000..e1c84f2e07 --- /dev/null +++ b/shopfloor/actions/data_detail.py @@ -0,0 +1,154 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.tools.float_utils import float_round + +from odoo.addons.component.core import Component +from odoo.addons.shopfloor_base.utils import ensure_model + + +class DataDetailAction(Component): + _inherit = "shopfloor.data.detail.action" + + def _select_value_to_label(self, rec, fname): + return rec._fields[fname].convert_to_export(rec[fname], rec) + + def location_detail(self, record, **kw): + return self._jsonify( + record.with_context(location=record.id), self._location_detail_parser, **kw + ) + + def locations_detail(self, record, **kw): + return self.location_detail(record, multi=True) + + @property + def _location_detail_parser(self): + return self._location_parser + [ + "complete_name", + ( + "reserved_move_line_ids:reserved_move_lines", + lambda record, fname: self.move_lines(record[fname]), + ), + ] + + @ensure_model("stock.picking") + def picking_detail(self, record, **kw): + parser = self._picking_detail_parser + # progress is a heavy computed field, + # and it may reduce performance significatively + # when dealing with a large number of pickings. + # Thus, we make it optional. + if "with_progress" in kw: + parser.append("progress") + return self._jsonify(record, parser, **kw) + + def pickings_detail(self, record, **kw): + return self.picking_detail(record, multi=True) + + @property + def _picking_detail_parser(self): + return self._picking_parser + [ + "picking_type_code", + ("priority", self._select_value_to_label), + "scheduled_date", + ("picking_type_id:operation_type", ["id", "name"]), + ( + "move_line_ids:move_lines", + lambda record, fname: self.move_lines(record[fname]), + ), + ] + + def package_detail(self, record, picking=None, **kw): + # Define a new method to not overload the base one which is used in many places + data = self.package(record, picking=picking, with_packaging=True, **kw) + data.update(self._jsonify(record, self._package_detail_parser, **kw)) + return data + + def packages_detail(self, records, picking=None, **kw): + return [self.package_detail(rec, picking=picking) for rec in records] + + @property + def _package_detail_parser(self): + return [ + ( + "reserved_move_line_ids:pickings", + lambda record, fname: self.pickings(record[fname].mapped("picking_id")), + ), + ( + "reserved_move_line_ids:move_lines", + lambda record, fname: self.move_lines(record[fname]), + ), + ("location_id:location", ["id", "display_name:name"]), + ] + + @ensure_model("stock.lot") + def lot_detail(self, record, **kw): + # Define a new method to not overload the base one which is used in many places + return self._jsonify(record, self._lot_detail_parser, **kw) + + def lots_detail(self, record, **kw): + return self.lot_detail(record, multi=True) + + @property + def _lot_detail_parser(self): + return self._lot_parser + [ + "removal_date", + "expiration_date:expire_date", + ( + "product_id:product", + lambda record, fname: self.product_detail(record[fname]), + ), + ] + + @ensure_model("product.product") + def product_detail(self, record, **kw): + # Defined new method to not overload the base one used in many places + data = self._jsonify(record, self._product_detail_parser, **kw) + suppliers = self.env["product.supplierinfo"].search( + [("product_id", "=", record.id)] + ) + data["suppliers"] = self._jsonify( + suppliers, self._product_supplierinfo_parser, multi=True + ) + return data + + def products_detail(self, record, **kw): + return self.product_detail(record, multi=True) + + @property + def _product_parser(self): + return super()._product_parser + [ + "qty_available", + ("free_qty:qty_reserved", self._product_reserved_qty_subparser), + ] + + def _product_reserved_qty_subparser(self, rec, field_name): + # free_qty = qty_available - reserved_quantity + return float_round( + rec.qty_available - rec[field_name], precision_rounding=rec.uom_id.rounding + ) + + @property + def _product_detail_parser(self): + return self._product_parser + [ + ("image_128:image", self._product_image_url), + ( + "product_tmpl_id:manufacturer", + lambda rec, fname: self._jsonify( + rec.product_tmpl_id.manufacturer_id, ["id", "name"] + ), + ), + ] + + def _product_image_url(self, record, field_name): + if not record[field_name]: + return None + return "/web/image/product.product/{}/{}".format(record.id, field_name) + + @property + def _product_supplierinfo_parser(self): + return [ + ("id", lambda rec, fname: rec.partner_id.id), + ("partner_id:partner", lambda rec, fname: rec.partner_id.name), + "product_name", + "product_code", + ] diff --git a/shopfloor/actions/inventory.py b/shopfloor/actions/inventory.py new file mode 100644 index 0000000000..f3c831acad --- /dev/null +++ b/shopfloor/actions/inventory.py @@ -0,0 +1,150 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, fields + +from odoo.addons.component.core import Component + + +class InventoryAction(Component): + """Provide methods to work with inventories + + Several processes have to create inventories at some point, + for instance when there is a stock issue. + """ + + _name = "shopfloor.inventory.action" + _inherit = "shopfloor.process.action" + _usage = "inventory" + + @property + def inventory_model(self): + # the _sf_inventory key bypass groups checks, + # see comment in models/stock_inventory.py + return self.env["stock.quant"].with_context(_sf_inventory=True) + + def create_draft_check_empty(self, location, product, ref=None): + """Create a draft inventory for a product with a zero quantity""" + return self._create_draft_inventory(location, product) + + def _inventory_exists(self, location, product, package=None, lot=None): + """Return if an inventory for location and product exist""" + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ("inventory_quantity_set", "=", True), + ] + if package is not None: + domain.append(("package_id", "=", package.id)) + if lot is not None: + domain.append(("lot_id", "=", lot.id)) + return self.inventory_model.search_count(domain) + + def _get_existing_quant(self, location, product, package=None, lot=None, limit=1): + domain = [("location_id", "=", location.id), ("product_id", "=", product.id)] + if package is not None: + domain.append(("package_id", "=", package.id)) + else: + domain.append(("package_id", "=", False)) + if lot is not None: + domain.append(("lot_id", "=", lot.id)) + else: + domain.append(("lot_id", "=", False)) + return self.inventory_model.search(domain, limit=limit) + + def _create_draft_inventory(self, location, product, package=None, lot=None): + quants = self._get_existing_quant( + location, product, package=package, lot=lot, limit=None + ) + if quants: + for quant in quants: + if quant.inventory_quantity_set: + continue + quants.write( + { + # Set an inventory quantity to prevent the zero quant cleanup + "inventory_quantity": quant.inventory_quantity + 1, + "inventory_date": fields.Date.today(), + } + ) + return quants + else: + return self.inventory_model.sudo().create( + { + "location_id": location.id, + "product_id": product.id, + "lot_id": lot.id, + "inventory_quantity": 1, + "inventory_date": fields.Date.today(), + "package_id": package.id if package else False, + } + ) + + def create_control_stock( + self, location, product, package=None, lot=None, name=None + ): + """Create a draft inventory so a user has to check a location + + If a draft or in progress inventory already exists for the same + combination of product/package/lot, no inventory is created. + """ + if not self._inventory_exists(location, product, package=package, lot=lot): + self._create_draft_inventory(location, product, package=package, lot=lot) + + def create_stock_issue(self, move, location, package, lot): + """Create an inventory for a stock issue + + It reduces the quantity in a location in a way that: + * assigned move lines in other batch transfers stay assigned. + * assigned move lines in same batch but already picked stay assigned. + """ + other_lines = self._stock_issue_get_related_move_lines( + move, location, package, lot + ) + qty_to_keep = sum(other_lines.mapped("reserved_qty")) + self.create_stock_correction(move, location, package, lot, qty_to_keep) + move._action_assign() + + def create_stock_correction(self, move, location, package, lot, quantity): + """Create an inventory with a forced quantity""" + quant = self._get_existing_quant(location, move.product_id, package, lot) + if quant: + quant.with_context( + inventory_mode=True + ).sudo().inventory_quantity_auto_apply = quantity + else: + self.inventory_model._update_available_quantity( + move.product_id, location, quantity, lot_id=lot, package_id=package + ) + # FIXME + move.product_id.stock_quant_ids._quant_tasks() + + def _stock_issue_get_related_move_lines(self, move, location, package, lot): + """Lookup for all the other moves lines that match given move line""" + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", move.product_id.id), + ("package_id", "=", package.id), + ("lot_id", "=", lot.id), + ("state", "in", ("assigned", "partially_available")), + ] + return self.env["stock.move.line"].search(domain) + + def _stock_correction_inventory_values( + self, move, location, package, lot, line_qty + ): + return { + "location_id": location.id, + "product_id": move.product_id.id, + "package_id": package.id, + "lot_id": lot.id, + "inventory_quantity": line_qty, + } + + def _stock_issue_product_description(self, product, package, lot): + parts = [] + if package: + parts.append(package.name) + parts.append(product.name) + if lot.name: + parts.append(_("Lot: ") + lot.name) + return " - ".join(parts) diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py new file mode 100644 index 0000000000..1ad4aa1f8b --- /dev/null +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -0,0 +1,89 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class LocationContentTransferSorter(Component): + + _name = "shopfloor.location.content.transfer.sorter" + _inherit = "shopfloor.process.action" + _usage = "location_content_transfer.sorter" + + def __init__(self, work_context): + super().__init__(work_context) + self._pickings = self.env["stock.picking"].browse() + self._content = None + + def feed_pickings(self, pickings): + self._pickings |= pickings + + def move_lines(self): + """Returns valid move lines. + + Valid move lines are: + - those not bound to a package level + - those bound to invalid package levels + + An invalid package level has one of its line not targetting the + expected package. + """ + # lines without package level only (raw products) + move_lines = self._pickings.move_line_ids.filtered( + lambda line: not line.package_level_id + and line.state not in ("cancel", "done") + ) + # lines with invalid package levels + invalid_levels = self._pickings.package_level_ids.filtered( + lambda level: level.state not in ("cancel", "done") + and any( + line.result_package_id != level.package_id + for line in level.move_line_ids + ) + ) + return move_lines | invalid_levels.move_line_ids + + def package_levels(self): + """Returns valid package levels. + + A valid package level has all its related move lines targetting + the expected package. + """ + return self._pickings.package_level_ids.filtered( + lambda level: level.state not in ("cancel", "done") + and all( + line.result_package_id == level.package_id + for line in level.move_line_ids + ) + ) + + @staticmethod + def _sort_key(content): + # content can be either a move line, either a package + # level + return ( + # postponed content after other contents + content.shopfloor_priority or 10, + # sort by shopfloor picking sequence + content.location_dest_id.shopfloor_picking_sequence or "", + # sort by similar destination + content.location_dest_id.complete_name, + # lines before packages (if we have raw products and packages, raw + # will be on top? wild guess) + 0 if content._name == "stock.move.line" else 1, + # to have a deterministic sort + content.id, + ) + + def sort(self): + content = [line for line in self.move_lines() if line] + [ + level for level in self.package_levels() if level + ] + self._content = sorted(content, key=self._sort_key) + + def __iter__(self): + if self._content is None: + self.sort() + return iter(self._content) + + def __next__(self): + return next(iter(self)) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py new file mode 100644 index 0000000000..4ec508f999 --- /dev/null +++ b/shopfloor/actions/message.py @@ -0,0 +1,846 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + +from odoo import _ + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class MessageAction(Component): + _inherit = "shopfloor.message.action" + + def no_operation_found(self): + return { + "message_type": "error", + "body": _("No operation found for this menu and profile."), + } + + def no_picking_type(self): + return { + "message_type": "error", + "body": _("No operation type found for this menu and profile."), + } + + def several_picking_types(self): + return { + "message_type": "error", + "body": _("Several operation types found for this menu and profile."), + } + + def package_not_found_for_barcode(self, barcode): + return { + "message_type": "error", + "body": _("The package %s doesn't exist") % barcode, + } + + def package_has_no_product_to_take(self, barcode): + return { + "message_type": "error", + "body": _("The package %s doesn't contain any product to take.") % barcode, + } + + def bin_not_found_for_barcode(self, barcode): + return {"message_type": "error", "body": _("Bin %s doesn't exist") % barcode} + + def package_not_allowed_in_src_location(self, barcode, picking_types): + return { + "message_type": "error", + "body": _( + "You cannot work on a package (%(barcode)s) outside of locations:" + " %(names)s" + ) + % dict( + barcode=barcode, + names=", ".join(picking_types.mapped("default_location_src_id.name")), + ), + } + + def location_requires_package(self): + return { + "message_type": "warning", + "body": _( + "This location requires packages. Please scan a destination package." + ), + } + + def already_running_ask_confirmation(self): + return { + "message_type": "warning", + "body": _("Operation's already running. Would you like to take it over?"), + } + + def scan_destination(self): + return {"message_type": "info", "body": _("Scan the destination location")} + + def scan_lot_on_product_tracked_by_lot(self): + return { + "message_type": "warning", + "body": _("Product tracked by lot, please scan one."), + } + + def operation_not_found(self): + return { + "message_type": "error", + "body": _("This operation does not exist anymore."), + } + + def stock_picking_not_found(self): + return { + "message_type": "error", + "body": _("This transfer does not exist or is not available anymore."), + } + + def package_not_found(self): + return { + "message_type": "error", + "body": _("This package does not exist anymore."), + } + + def package_different_change(self): + return { + "message_type": "warning", + "body": _( + "You scanned a different package with the same product, " + "do you want to change pack? Scan it again to confirm" + ), + } + + def package_not_available_in_picking(self, package, picking): + return { + "message_type": "warning", + "body": _( + "Package %(package_name)s is not available in transfer %(picking_name)s." + ).format(package.name, picking.name), + } + + def package_not_empty(self, package): + return { + "message_type": "warning", + "body": _("Package {} is not empty.").format(package.name), + } + + def package_already_used(self, package): + return { + "message_type": "warning", + "body": _("Package {} is already used.").format(package.name), + } + + def dest_package_required(self): + return { + "message_type": "warning", + "body": _("A destination package is required."), + } + + def line_not_available_in_picking(self, picking): + return { + "message_type": "warning", + "body": _("This line is not available in transfer {}.").format( + picking.name + ), + } + + def record_not_found(self): + return { + "message_type": "error", + "body": _("The record you were working on does not exist anymore."), + } + + def barcode_not_found(self): + return {"message_type": "error", "body": _("Barcode not found")} + + def operation_has_been_canceled_elsewhere(self): + return { + "message_type": "warning", + "body": _("Restart the operation, someone has canceled it."), + } + + def no_location_found(self): + return { + "message_type": "error", + "body": _("No location found for this barcode."), + } + + def location_not_allowed(self): + return {"message_type": "error", "body": _("Location not allowed here.")} + + def dest_location_not_allowed(self): + return {"message_type": "error", "body": _("You cannot place it here")} + + def need_confirmation(self): + return {"message_type": "warning", "body": _("Are you sure?")} + + def confirm_location_changed(self, from_location, to_location): + return { + "message_type": "warning", + "body": _( + "Confirm location change from %(location_from)s to " "%(location_to)s?" + ) + % dict(location_from=from_location.name, location_to=to_location.name), + } + + def confirm_pack_moved(self): + return { + "message_type": "success", + "body": _("The pack has been moved, you can scan a new pack."), + } + + def already_done(self): + return {"message_type": "info", "body": _("Operation already processed.")} + + def move_already_done(self): + return {"message_type": "warning", "body": _("Move already processed.")} + + def confirm_canceled_scan_next_pack(self): + return { + "message_type": "success", + "body": _("Canceled, you can scan a new pack."), + } + + def no_pack_in_location(self, location): + return { + "message_type": "error", + "body": _("Location %s doesn't contain any package.") % location.name, + } + + def several_packs_in_location(self, location): + return { + "message_type": "warning", + "body": _("Several packages found in %(name)s, please scan a package.") + % dict(name=location.name), + } + + def no_package_or_lot_for_barcode(self, barcode): + return { + "message_type": "error", + "body": _("No package or lot found for barcode {}.").format(barcode), + } + + def no_product_for_barcode(self, barcode): + return { + "message_type": "error", + "body": _("No product found for {}").format(barcode), + } + + def wrong_product(self): + # Method to drop in v15 + _logger.warning("`wrong_product` is deprecated, use `wrong_record` instead") + return { + "message_type": "error", + "body": self._wrong_record_msg("product.product"), + } + + def _wrong_record_msg(self, model_name): + return { + "product.product": _("Wrong product."), + "stock.lot": _("Wrong lot."), + "stock.location": _("Wrong location."), + "stock.quant.package": _("Wrong pack."), + "product.packaging": _("Wrong packaging."), + }.get(model_name, _("Wrong.")) + + def wrong_record(self, record): + return {"message_type": "error", "body": self._wrong_record_msg(record._name)} + + def no_lot_for_barcode(self, barcode): + return { + "message_type": "error", + "body": _("No lot found for {}").format(barcode), + } + + def lot_on_wrong_product(self, barcode): + return { + "message_type": "error", + "body": _("Lot {} is for another product.").format(barcode), + } + + def wrong_lot(self): + # Method to drop in v15 + _logger.warning("`wrong_log` is deprecated, use `wrong_record` instead") + return { + "message_type": "error", + "body": self._wrong_record_msg("stock.lot"), + } + + def several_lots_in_location(self, location): + return { + "message_type": "warning", + "body": _("Several lots found in %s, please scan a lot.") % location.name, + } + + def several_lots_in_package(self, package): + return { + "message_type": "error", + "body": _("Several lots found in %(name)s, please scan the lot.") + % dict(name=package.name), + } + + def several_move_in_different_location(self): + return { + "message_type": "warning", + "body": _( + "Several moves found on different locations, please scan first the location." + ), + } + + def several_move_with_different_lot(self): + return { + "message_type": "warning", + "body": _("Several moves found for different lots, please scan the lot."), + } + + def several_products_in_location(self, location): + return { + "message_type": "warning", + "body": _("Several products found in %(name)s, please scan a product.") + % dict(name=location.name), + } + + def several_products_in_package(self, package): + return { + "message_type": "error", + "body": _("Several products found in %(name)s, please scan the product.") + % dict(name=package.name), + } + + def no_product_in_location(self, location): + return { + "message_type": "error", + "body": _("No product found in {}").format(location.name), + } + + def no_pending_operation_for_pack(self, pack): + return { + "message_type": "error", + "body": _("No pending operation for package %s.") % pack.name, + } + + def no_putaway_destination_available(self): + return { + "message_type": "error", + "body": _("No putaway destination is available."), + } + + def package_unable_to_transfer(self, pack): + return { + "message_type": "error", + "body": _("The package %s cannot be transferred with this scenario.") + % pack.name, + } + + def unrecoverable_error(self): + return { + "message_type": "error", + "body": _("Unrecoverable error, please restart."), + } + + def package_different_content(self, package): + return { + "message_type": "error", + "body": _("Package {} has a different content.").format(package.name), + } + + def package_change_error_same_package(self, package): + return { + "message_type": "error", + "body": _("Same package {} is already assigned.").format(package.name), + } + + def x_units_put_in_package(self, qty, product, package): + return { + "message_type": "success", + "body": _( + "%(qty)s %(product_name)s put in %(package_name)s", + qty=qty, + product_name=product.display_name, + package_name=package.name, + ), + } + + def cannot_move_something_in_picking_type(self): + return { + "message_type": "error", + "body": _("You cannot move this using this menu."), + } + + def stock_picking_not_available(self, picking): + return { + "message_type": "error", + "body": _("Transfer {} is not available.").format(picking.name), + } + + def line_has_package_scan_package(self): + return { + "message_type": "warning", + "body": _("This line has a package, please select the package instead."), + } + + def scan_the_location_first(self): + return { + "message_type": "warning", + "body": _("Please scan the location first."), + } + + def scan_the_package(self): + return { + "message_type": "warning", + "body": _("Please scan the package."), + } + + def product_multiple_packages_scan_package(self): + return { + "message_type": "warning", + "body": _( + _("This product is part of multiple packages, please scan a package.") + ), + } + + def source_document_multiple_pickings_scan_package(self): + return { + "message_type": "warning", + "body": _( + _( + "This source document is part of multiple transfers, please scan a package." + ) + ), + } + + def product_mixed_package_scan_package(self): + return { + "message_type": "warning", + "body": _( + "This product is part of a package with other products, " + "please scan a package." + ), + } + + def product_not_unitary_in_package_scan_package(self): + return { + "message_type": "warning", + "body": _("This product is part of a package, please scan a package."), + } + + def product_not_found(self): + return { + "message_type": "error", + "body": _("This product does not exist anymore."), + } + + def product_not_found_in_pickings(self): + return { + "message_type": "warning", + "body": _("No transfer found for this product."), + } + + def x_not_found_or_already_in_dest_package(self, message_code): + return { + "message_type": "warning", + "body": _( + "{message_code} not found in the current transfer or already in a package." + ).format(message_code=message_code), + } + + def packaging_not_found_in_picking(self): + return { + "message_type": "warning", + "body": _("Packaging not found in the current transfer."), + } + + def expiration_date_missing(self): + return { + "message_type": "error", + "body": _("Missing expiration date."), + } + + def multiple_picks_found_select_manually(self): + return { + "message_type": "error", + "body": _("Several transfers found, please select a transfer manually."), + } + + def no_transfer_for_packaging(self): + return { + "message_type": "error", + "body": _("No transfer found for the scanned packaging."), + } + + def no_transfer_for_lot(self): + return { + "message_type": "error", + "body": _("No transfer found for the scanned lot."), + } + + def create_new_pack_ask_confirmation(self, barcode): + return { + "message_type": "warning", + "body": _("Create new PACK {}? Scan it again to confirm.").format(barcode), + } + + def place_in_location_ask_confirmation(self, location_name): + return { + "message_type": "warning", + "body": _("Place it in {}?").format(location_name), + } + + def product_not_found_in_current_picking(self): + return { + "message_type": "error", + "body": _("Product is not in the current transfer."), + } + + def lot_mixed_package_scan_package(self): + return { + "message_type": "warning", + "body": _( + "This lot is part of a package with other products, " + "please scan a package." + ), + } + + def lot_multiple_packages_scan_package(self): + return { + "message_type": "warning", + "body": _("This lot is part of multiple packages, please scan a package."), + } + + def lot_not_found_in_pickings(self): + return { + "message_type": "warning", + "body": _("No transfer found for this lot."), + } + + def batch_transfer_complete(self): + return { + "message_type": "success", + "body": _("Batch Transfer complete"), + } + + def batch_transfer_line_done(self): + return { + "message_type": "success", + "body": _("Batch Transfer line done"), + } + + def transfer_complete(self, picking): + return { + "message_type": "success", + "body": _("Transfer {} complete").format(picking.name), + } + + def location_content_transfer_item_complete(self, location_dest): + return { + "message_type": "success", + "body": _("Content transfer to {} completed").format(location_dest.name), + } + + def location_content_transfer_complete(self, location_src, location_dest): + return { + "message_type": "success", + "body": _( + "Content transferred from %(location_name)s to %(location_dest_name)s." + ).format(location_src.name, location_dest.name), + } + + def location_content_unable_to_transfer(self, location_dest): + return { + "message_type": "error", + "body": _( + "The content of {} cannot be transferred with this scenario." + ).format(location_dest.name), + } + + def product_in_multiple_sublocation(self, product): + return { + "message_type": "warning", + "body": _( + "Product {} found in multiple locations. Scan your location first." + ).format(product.name), + } + + def lot_in_multiple_sublocation(self, lot): + return { + "message_type": "warning", + "body": _( + "Lot {lot} for product {product} found in multiple locations. " + "Scan your location first." + ).format(lot=lot.name, product=lot.product_id.name), + } + + def no_default_location_on_picking_type(self): + return { + "message_type": "error", + "body": _( + "Operation types for this menu are missing " + "default source and destination locations." + ), + } + + def location_src_set_to_sublocation(self, location_src): + return { + "message_type": "success", + "body": _("Working location changed to {}").format(location_src.name), + } + + def picking_already_started_in_location(self, pickings): + return { + "message_type": "error", + "body": _( + "Picking has already been started in this location in transfer(s): {}" + ).format(", ".join(pickings.mapped("name"))), + } + + def transfer_done_success(self, picking): + return { + "message_type": "success", + "body": _("Transfer {} done").format(picking.name), + } + + def transfer_confirm_done(self): + return { + "message_type": "warning", + "body": _( + "Not all lines have been processed with full quantity. " + "Do you confirm partial operation?" + ), + } + + def move_already_returned(self): + return { + "message_type": "error", + "body": _("The product/packaging you selected has already been returned."), + } + + def return_line_invalid_qty(self): + return { + "message_type": "error", + "body": _("You cannot return more quantity than what was initially sent."), + } + + def transfer_no_qty_done(self): + return { + "message_type": "warning", + "body": _( + "No quantity has been processed, unable to complete the transfer." + ), + } + + def picking_zero_quantity(self): + return { + "message_type": "error", + "body": _("The picked quantity must be a value above zero."), + } + + def selected_lines_qty_done_higher_than_allowed(self): + return { + "message_type": "warning", + "body": _( + "The quantity scanned for one or more lines cannot be " + "higher than the maximum allowed." + ), + } + + def line_scanned_qty_done_higher_than_allowed(self): + return { + "message_type": "warning", + "body": _( + "Please note that the scanned quantity is higher than the maximum allowed." + ), + } + + def recovered_previous_session(self): + return { + "message_type": "info", + "body": _("Recovered previous session."), + } + + def no_lines_to_process(self): + return { + "message_type": "info", + "body": _("No lines to process."), + } + + def location_empty(self, location): + return { + "message_type": "error", + "body": _("Location {} empty").format(location.name), + } + + def location_empty_scan_package(self, location): + return { + "message_type": "warning", + "body": _("Location empty. Try scanning a package"), + } + + def location_not_found(self): + return { + "message_type": "error", + "body": _("This location does not exist."), + } + + def unable_to_pick_more(self, quantity): + return { + "message_type": "error", + "body": _("You must not pick more than {} units.").format(quantity), + } + + def lot_replaced_by_lot(self, old_lot, new_lot): + return { + "message_type": "success", + "body": _("Lot %(old_lot_name)s replaced by lot %(new_lot_name)s.").format( + old_lot.name, new_lot.name + ), + } + + def package_replaced_by_package(self, old_package, new_package): + return { + "message_type": "success", + "body": _( + "Package %(old_package_name)s replaced by package %(new_package_name)s." + ).format(old_package.name, new_package.name), + } + + def package_already_picked_by(self, package, picking): + return { + "message_type": "error", + "body": _( + "Package %(package_name)s cannot be picked, already moved " + "by transfer %(picking_name)s.", + package_name=package.name, + picking_name=picking.name, + ), + } + + def units_replaced_by_package(self, new_package): + return { + "message_type": "success", + "body": _("Units replaced by package {}.").format(new_package.name), + } + + def package_change_error(self, package, error_msg): + return { + "message_type": "error", + "body": _( + "Package %(package_name)s cannot be used: %(error)s", + package_name=package.name, + error=error_msg, + ), + } + + def cannot_change_lot_already_picked(self, lot): + return { + "message_type": "error", + "body": _("Cannot change to lot {} which is entirely picked.").format( + lot.name + ), + } + + def buffer_complete(self): + return { + "message_type": "success", + "body": _("All packages processed."), + } + + def picking_type_complete(self, picking_type): + return { + "message_type": "success", + "body": _("Picking type {} complete.").format(picking_type.name), + } + + def barcode_no_match(self, barcode): + return { + "message_type": "warning", + "body": _("Barcode does not match with {}.").format(barcode), + } + + def lines_different_dest_location(self): + return { + "message_type": "error", + "body": _("Lines have different destination location."), + } + + def new_move_lines_not_assigned(self): + return { + "message_type": "error", + "body": _("New move lines cannot be assigned: canceled."), + } + + def package_open(self): + return { + "message_type": "info", + "body": _("Package has been opened. You can move partial quantities."), + } + + def packaging_invalid_for_carrier(self, packaging, carrier): + return { + "message_type": "error", + "body": _( + "Packaging '%(package_name)s' is not allowed for carrier " + "%(carrier_name)s.or carrier %(carrier_name)s.", + package_name=packaging.name if packaging else _("No value"), + carrier_name=carrier.name, + ), + } + + def dest_package_not_valid(self, package): + return { + "message_type": "error", + "body": _("{} is not a valid destination package.").format(package.name), + } + + def no_valid_package_to_select(self): + return { + "message_type": "warning", + "body": _("No valid package to select."), + } + + def no_delivery_packaging_available(self): + return { + "message_type": "warning", + "body": _("No delivery package type available."), + } + + def goods_packed_in(self, package): + return { + "message_type": "info", + "body": _("Goods packed into {0.name}").format(package), + } + + def picking_without_carrier_cannot_pack(self, picking): + return { + "message_type": "error", + "body": _( + "Pick + Pack mode ON: the picking {0.name} has no carrier set. " + "The system couldn't pack goods automatically." + ).format(picking), + } + + def no_work_found(self): + return { + "message_type": "warning", + "body": _("No available work could be found."), + } + + def confirm_put_all_goods_in_delivery_package(self, packaging_type): + return { + "message_type": "warning", + "body": _( + "Delivery package type scanned: %(name)s. " + "Scan again to place all goods in the same package." + ) + % dict(name=packaging_type.name), + } + + def location_contains_only_packages_scan_one(self): + return { + "message_type": "warning", + "body": _("This location only contains packages, please scan one of them."), + } + + def no_line_to_pack(self): + return { + "message_type": "warning", + "body": _("No line to pack found."), + } diff --git a/shopfloor/actions/move_line_search.py b/shopfloor/actions/move_line_search.py new file mode 100644 index 0000000000..87b9bcd177 --- /dev/null +++ b/shopfloor/actions/move_line_search.py @@ -0,0 +1,119 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class MoveLineSearch(Component): + """Provide methods to search move line records. + + The methods should be used in Service Components, so a search will always + have the same result in all scenarios. + """ + + _name = "shopfloor.search.move.line" + _inherit = "shopfloor.process.action" + _usage = "search_move_line" + + @property + def picking_types(self): + return getattr( + self.work, "picking_types", self.env["stock.picking.type"].browse() + ) + + def _search_move_lines_by_location_domain( + self, + locations, + picking_type=None, + package=None, + product=None, + lot=None, + match_user=False, + picking_ready=True, + # When True, adds the package in the domain even if the package is False + enforce_empty_package=False, + ): + domain = [ + ("location_id", "child_of", locations.ids), + ("qty_done", "=", 0), + ("state", "in", ("assigned", "partially_available")), + ] + if picking_type: + # auto_join in place for this field + domain += [("picking_id.picking_type_id", "=", picking_type.id)] + elif self.picking_types: + domain += [("picking_id.picking_type_id", "in", self.picking_types.ids)] + if package or package is not None and enforce_empty_package: + domain += [("package_id", "=", package.id if package else False)] + if product: + domain += [("product_id", "=", product.id)] + if lot: + domain += [("lot_id", "=", lot.id)] + if match_user: + domain += [ + "|", + ("shopfloor_user_id", "=", False), + ("shopfloor_user_id", "=", self.env.uid), + ] + if picking_ready: + domain += [("picking_id.state", "=", "assigned")] + return domain + + def search_move_lines_by_location( + self, + locations, + picking_type=None, + package=None, + product=None, + lot=None, + order="priority", + match_user=False, + sort_keys_func=None, + picking_ready=True, + enforce_empty_package=False, + ): + """Find lines that potentially need work in given locations.""" + move_lines = self.env["stock.move.line"].search( + self._search_move_lines_by_location_domain( + locations, + picking_type, + package, + product, + lot, + match_user=match_user, + picking_ready=picking_ready, + enforce_empty_package=enforce_empty_package, + ) + ) + sort_keys_func = sort_keys_func or self._sort_key_move_lines(order) + move_lines = move_lines.sorted(sort_keys_func) + return move_lines + + @staticmethod + def _sort_key_move_lines(order): + """Return a sorting function to order lines.""" + + if order == "priority": + # make prority negative to keep sorting ascending + return lambda line: ( + -int(line.move_id.priority or "0"), + line.move_id.date, + line.move_id.id, + ) + elif order == "location": + return lambda line: ( + line.move_id.location_id.shopfloor_picking_sequence or "", + line.move_id.location_id.name, + line.move_id.date, + line.move_id.id, + ) + return lambda line: line + + def counters_for_lines(self, lines): + # Not using mapped/filtered to support simple lists and generators + priority_lines = [x for x in lines if int(x.picking_id.priority or "0") > 0] + return { + "lines_count": len(lines), + "picking_count": len({x.picking_id.id for x in lines}), + "priority_lines_count": len(priority_lines), + "priority_picking_count": len({x.picking_id.id for x in priority_lines}), + } diff --git a/shopfloor/actions/packaging.py b/shopfloor/actions/packaging.py new file mode 100644 index 0000000000..c7be1c773b --- /dev/null +++ b/shopfloor/actions/packaging.py @@ -0,0 +1,59 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class PackagingAction(Component): + """Provide methods to work with packaging operations.""" + + _name = "shopfloor.packaging.action" + _inherit = "shopfloor.process.action" + _usage = "packaging" + + def packaging_valid_for_carrier(self, packaging, carrier): + return self.packaging_type_valid_for_carrier(packaging.package_type_id, carrier) + + def packaging_type_valid_for_carrier(self, packaging_type, carrier): + return packaging_type.package_carrier_type in ( + "none", + carrier.delivery_type, + ) + + def create_delivery_package(self, carrier): + default_packaging = self._get_default_packaging(carrier) + return self.create_package_from_packaging(default_packaging) + + def _get_default_packaging(self, carrier): + # TODO: refactor `delivery_[carrier_name]` modules + # to have always the same field named `default_packaging_id` + # to unify lookup of this field. + # As alternative add a computed field. + # AFAIS there's no reason to have 1 field per carrier type. + fname = carrier.delivery_type + "_default_packaging_id" + if fname not in carrier._fields: + return self.env["stock.package.type"].browse() + return carrier[fname] + + def create_package_from_packaging(self, packaging=None): + if packaging: + vals = self._package_vals_from_packaging(packaging) + else: + vals = self._package_vals_without_packaging() + return self.env["stock.quant.package"].create(vals) + + def _package_vals_from_packaging(self, packaging): + return { + "package_type_id": packaging.id, + "pack_length": packaging.packaging_length, + "width": packaging.width, + "height": packaging.height, + } + + def _package_vals_without_packaging(self): + return {} + + def package_has_several_products(self, package): + return len(package.quant_ids.product_id) > 1 + + def package_has_several_lots(self, package): + return len(package.quant_ids.lot_id) > 1 diff --git a/shopfloor/actions/savepoint.py b/shopfloor/actions/savepoint.py new file mode 100644 index 0000000000..8aea40287d --- /dev/null +++ b/shopfloor/actions/savepoint.py @@ -0,0 +1,44 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import uuid + +from psycopg2 import sql + +from odoo.addons.component.core import Component + + +class SavepointBuilder(Component): + """Return a new Savepoint instance""" + + _name = "shopfloor.savepoint.action" + _inherit = "shopfloor.process.action" + _usage = "savepoint" + + def new(self): + return Savepoint(self.env.cr) + + +class Savepoint(object): + """Wrapper for SQL Savepoint + + Close to "cr.savepoint()" context manager but this class gives more control + over when the release/rollback are called. + """ + + def __init__(self, cr): + self._cr = cr + self.name = uuid.uuid1().hex + self._cr.flush() + self._execute("SAVEPOINT {}") + + def rollback(self): + self._cr.clear() + self._execute("ROLLBACK TO SAVEPOINT {}") + + def release(self): + self._cr.flush() + self._execute("RELEASE SAVEPOINT {}") + + def _execute(self, query): + # pylint: disable=sql-injection + self._cr.execute(sql.SQL(query).format(sql.Identifier(self.name))) diff --git a/shopfloor/actions/schema.py b/shopfloor/actions/schema.py new file mode 100644 index 0000000000..0910f61d1a --- /dev/null +++ b/shopfloor/actions/schema.py @@ -0,0 +1,182 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class ShopfloorSchemaAction(Component): + + _inherit = "shopfloor.schema.action" + + def picking(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "origin": {"type": "string", "nullable": True, "required": False}, + "note": {"type": "string", "nullable": True, "required": False}, + "move_line_count": {"type": "integer", "nullable": True, "required": True}, + "package_level_count": { + "type": "integer", + "nullable": True, + "required": True, + }, + "bulk_line_count": {"type": "integer", "nullable": True, "required": True}, + "weight": {"required": True, "nullable": True, "type": "float"}, + "partner": self._schema_dict_of(self._simple_record()), + "carrier": self._schema_dict_of(self._simple_record(), required=False), + "ship_carrier": self._schema_dict_of(self._simple_record(), required=False), + "scheduled_date": {"type": "string", "nullable": False, "required": True}, + "progress": {"type": "float", "nullable": True}, + } + + def move_line(self, with_packaging=False, with_picking=False): + schema = { + "id": {"type": "integer", "required": True}, + "qty_done": {"type": "float", "required": True}, + "quantity": {"type": "float", "required": True}, + "product": self._schema_dict_of(self.product()), + "lot": { + "type": "dict", + "required": False, + "nullable": True, + "schema": self.lot(), + }, + "package_src": self._schema_dict_of( + self.package(with_packaging=with_packaging) + ), + "package_dest": self._schema_dict_of( + self.package(with_packaging=with_packaging), required=False + ), + "location_src": self._schema_dict_of(self.location()), + "location_dest": self._schema_dict_of(self.location()), + "priority": {"type": "string", "nullable": True, "required": False}, + "progress": {"type": "float", "nullable": True}, + } + if with_picking: + schema["picking"] = self._schema_dict_of(self.picking()) + return schema + + def move(self): + return { + "id": {"required": True, "type": "integer"}, + "priority": {"type": "string", "required": False, "nullable": True}, + "quantity_done": {"type": "float", "required": True}, + "quantity": {"type": "float", "required": True}, + "product": self._schema_dict_of(self.product()), + "location_src": self._schema_dict_of(self.location()), + "location_dest": self._schema_dict_of(self.location()), + "progress": {"type": "float", "nullable": True}, + } + + def product(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "display_name": {"type": "string", "nullable": False, "required": True}, + "default_code": {"type": "string", "nullable": True, "required": True}, + "barcode": {"type": "string", "nullable": True, "required": False}, + "supplier_code": {"type": "string", "nullable": True, "required": False}, + "packaging": self._schema_list_of(self.packaging()), + "uom": self._schema_dict_of( + self._simple_record( + factor={"required": True, "nullable": True, "type": "float"}, + rounding={"required": True, "nullable": True, "type": "float"}, + ) + ), + } + + def package(self, with_packaging=False): + schema = { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "weight": {"required": True, "nullable": True, "type": "float"}, + "move_line_count": {"required": False, "nullable": True, "type": "integer"}, + "storage_type": self._schema_dict_of(self._simple_record()), + } + if with_packaging: + schema["packaging"] = self._schema_dict_of(self.packaging()) + return schema + + def lot(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "ref": {"type": "string", "nullable": True, "required": False}, + "expiration_date": {"type": "string", "nullable": True, "required": False}, + } + + def location(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "barcode": {"type": "string", "nullable": True, "required": False}, + "operation_progress": { + "type": "dict", + "required": False, + "schema": { + "done": {"type": "float", "required": False}, + "to_do": {"type": "float", "required": False}, + }, + }, + } + + def packaging(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "code": {"type": "string", "nullable": True, "required": True}, + "qty": {"type": "float", "required": True}, + } + + def delivery_packaging(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "packaging_type": {"type": "string", "nullable": True, "required": True}, + "barcode": {"type": "string", "nullable": True, "required": True}, + } + + def picking_batch(self, with_pickings=False): + schema = { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "picking_count": {"required": True, "type": "integer"}, + "move_line_count": {"required": True, "type": "integer"}, + "weight": {"required": True, "nullable": True, "type": "float"}, + } + if with_pickings: + schema["pickings"] = self._schema_list_of(self.picking()) + return schema + + def package_level(self): + return { + "id": {"required": True, "type": "integer"}, + "is_done": {"type": "boolean", "nullable": False, "required": True}, + "picking": self._schema_dict_of(self._simple_record()), + "package_src": self._schema_dict_of(self.package()), + "location_src": self._schema_dict_of(self.location()), + "location_dest": self._schema_dict_of(self.location()), + "product": self._schema_dict_of(self.product()), + "quantity": {"type": "float", "required": True}, + } + + def picking_type(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } + + def move_lines_counters(self): + return { + "lines_count": {"type": "float", "required": False, "nullable": True}, + "picking_count": {"type": "float", "required": False, "nullable": True}, + "priority_lines_count": { + "type": "float", + "required": False, + "nullable": True, + }, + "priority_picking_count": { + "type": "float", + "required": False, + "nullable": True, + }, + } diff --git a/shopfloor/actions/schema_detail.py b/shopfloor/actions/schema_detail.py new file mode 100644 index 0000000000..dff7e61426 --- /dev/null +++ b/shopfloor/actions/schema_detail.py @@ -0,0 +1,98 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class ShopfloorSchemaDetailAction(Component): + _inherit = "shopfloor.schema.detail.action" + + def location_detail(self): + schema = self.location() + schema.update( + { + "complete_name": { + "type": "string", + "nullable": False, + "required": True, + }, + "reserved_move_lines": self._schema_list_of(self.move_line()), + } + ) + return schema + + def picking_detail(self): + schema = self.picking() + schema.update( + { + "picking_type_code": { + "type": "string", + "nullable": True, + "required": False, + }, + "priority": {"type": "string", "nullable": True, "required": False}, + "operation_type": self._schema_dict_of(self._simple_record()), + "move_lines": self._schema_list_of(self.move_line()), + } + ) + return schema + + def package_detail(self): + schema = self.package(with_packaging=True) + schema.update( + { + "pickings": self._schema_list_of(self.picking()), + "move_lines": self._schema_list_of(self.move_line()), + "location": self._schema_dict_of(self._simple_record()), + } + ) + return schema + + def lot_detail(self): + schema = self.lot() + schema.update( + { + "removal_date": {"type": "string", "nullable": True, "required": False}, + "expire_date": {"type": "string", "nullable": True, "required": False}, + "product": self._schema_dict_of(self.product_detail()), + # TODO: packaging + } + ) + return schema + + def product(self): + schema = super().product() + schema.update( + { + "qty_available": {"type": "float", "required": True}, + "qty_reserved": {"type": "float", "required": True}, + } + ) + return schema + + def product_detail(self): + schema = self.product() + schema.update( + { + "image": {"type": "string", "nullable": True, "required": False}, + "manufacturer": self._schema_dict_of(self._simple_record()), + "suppliers": self._schema_list_of(self.product_supplierinfo()), + } + ) + return schema + + def product_supplierinfo(self): + return { + "id": {"required": True, "type": "integer"}, + "partner": {"type": "string", "nullable": True, "required": False}, + "product_name": {"type": "string", "nullable": True, "required": False}, + "product_code": {"type": "string", "nullable": True, "required": False}, + } + + # TODO + # def packaging_detail(self): + # schema = self.packaging() + # schema.update( + # { + # } + # ) + # return schema diff --git a/shopfloor/actions/search.py b/shopfloor/actions/search.py new file mode 100644 index 0000000000..86d692e61e --- /dev/null +++ b/shopfloor/actions/search.py @@ -0,0 +1,187 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.osv.expression import AND + +from odoo.addons.component.core import Component + + +class SearchResult: + + __slots__ = ("record", "type", "code") + + def __init__(self, **kw) -> None: + for k in self.__slots__: + setattr(self, k, kw.get(k)) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}: type={self.type} code={self.code}>" + + def __bool__(self): + return self.type != "none" or bool(self.record) + + def __eq__(self, other): + for k in self.__slots__: + if not hasattr(other, k): + return False + if getattr(other, k) != getattr(self, k): + return False + return True + + @property + def records(self): + """In some cases we expect more than one records (eg: location limit > 1) or lots""" + return self.record if len(self.record) > 1 else None + + +class SearchAction(Component): + """Provide methods to search records from scanner + + The methods should be used in Service Components, so a search will always + have the same result in all scenarios. + """ + + _inherit = "shopfloor.search.action" + + @property + def _barcode_type_handler(self): + return { + "product": self.product_from_scan, + "package": self.package_from_scan, + "picking": self.picking_from_scan, + "location": self.location_from_scan, + "location_dest": self.location_from_scan, + "lot": self.lot_from_scan, + "serial": self.lot_from_scan, + "packaging": self.packaging_from_scan, + "delivery_packaging": self.delivery_packaging_from_scan, + "origin_move": self.origin_move_from_scan, + } + + def _make_search_result(self, **kwargs): + """Build a 'SearchResult' object describing the record found. + + If no record has been found, the SearchResult object will have + its 'type' defined to "none". + """ + return SearchResult(**kwargs) + + def find(self, barcode, types=None, handler_kw=None): + """Find Odoo record matching given `barcode`. + + Plain barcodes + """ + barcode = barcode or "" + return self.generic_find(barcode, types=types, handler_kw=handler_kw) + + def generic_find(self, barcode, types=None, handler_kw=None): + handler_kw = handler_kw or {} + _types = types or self._barcode_type_handler.keys() + # TODO: decide the best default order in case we don't pass `types` + for btype in _types: + handler = self._barcode_type_handler.get(btype) + if not handler: + continue + record = handler(barcode, **handler_kw.get(btype, {})) + if record: + return self._make_search_result(record=record, code=barcode, type=btype) + + return self._make_search_result(type="none") + + def location_from_scan(self, barcode, limit=1): + model = self.env["stock.location"] + if not barcode: + return model.browse() + # First search location by barcode + res = model.search([("barcode", "=", barcode)], limit=limit) + # And only if we have not found through barcode search on the location name + if len(res) < limit: + res |= model.search([("name", "=", barcode)], limit=(limit - len(res))) + return res + + def package_from_scan(self, barcode): + model = self.env["stock.quant.package"] + if not barcode: + return model.browse() + return model.search([("name", "=", barcode)], limit=1) + + def picking_from_scan(self, barcode, use_origin=False): + model = self.env["stock.picking"] + if not barcode: + return model.browse() + picking = model.search([("name", "=", barcode)], limit=1) + # We need to split the domain in two different searches + # as there might be a case where + # the name of a picking is the same as the origin of another picking + # (e.g. in a backorder) and we need to make sure + # the name search takes priority. + if picking: + return picking + if use_origin: + source_document_domain = [ + # We could have the same origin for multiple transfers + # but we're interested only in the "assigned" ones. + ("origin", "=", barcode), + ("state", "=", "assigned"), + ] + return model.search(source_document_domain) + return model.browse() + + def product_from_scan(self, barcode): + model = self.env["product.product"] + if not barcode: + return model.browse() + return model.search( + [ + "|", + ("barcode", "=", barcode), + ("default_code", "=", barcode), + ], + limit=1, + ) + + def lot_from_scan(self, barcode, products=None, limit=1): + model = self.env["stock.lot"] + if not barcode: + return model.browse() + domain = [ + ("company_id", "=", self.env.company.id), + ("name", "=", barcode), + ] + if products: + domain.append(("product_id", "in", products.ids)) + return model.search(domain, limit=limit) + + def packaging_from_scan(self, barcode): + model = self.env["product.packaging"] + if not barcode: + return model.browse() + return model.search( + [("barcode", "=", barcode), ("product_id", "!=", False)], limit=1 + ) + + def generic_packaging_from_scan(self, barcode): + model = self.env["product.packaging"] + if not barcode: + return model.browse() + return model.search( + [("barcode", "=", barcode), ("product_id", "=", False)], limit=1 + ) + + def delivery_packaging_from_scan(self, barcode): + model = self.env["stock.package.type"] + if not barcode: + return model.browse() + return model.search([("barcode", "=", barcode)], limit=1) + + def origin_move_from_scan(self, barcode, extra_domain=None): + model = self.env["stock.move"] + outgoing_move_domain = [ + # We could have the same origin for multiple transfers + # but we're interested only in the "done" ones. + ("origin", "=", barcode), + ("state", "=", "done"), + ] + if extra_domain: + outgoing_move_domain = AND([outgoing_move_domain, extra_domain]) + return model.search(outgoing_move_domain) diff --git a/shopfloor/actions/stock.py b/shopfloor/actions/stock.py new file mode 100644 index 0000000000..ba34b36ab9 --- /dev/null +++ b/shopfloor/actions/stock.py @@ -0,0 +1,239 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, fields +from odoo.tools.float_utils import float_round + +from odoo.addons.component.core import Component + +from ..exceptions import ConcurentWorkOnTransfer + + +class StockAction(Component): + """Provide methods to work with stock operations.""" + + _name = "shopfloor.stock.action" + _inherit = "shopfloor.process.action" + _usage = "stock" + + def _create_return_move__get_max_qty(self, origin_move): + """Returns the max returneable qty.""" + # The max returnable qty is the sent qty minus the already returned qties + quantity = origin_move.reserved_qty + for move in origin_move.move_dest_ids: + if ( + move.origin_returned_move_id + and move.origin_returned_move_id != origin_move + ): + continue + if move.state in ("partially_available", "assigned"): + quantity -= sum(move.move_line_ids.mapped("reserved_qty")) + elif move.state in ("done"): + quantity -= move.reserved_qty + return float_round( + quantity, precision_rounding=origin_move.product_id.uom_id.rounding + ) + + def _create_return_move__get_vals(self, return_picking, origin_move): + product = origin_move.product_id + return_type = return_picking.picking_type_id + return { + "product_id": product.id, + "product_uom": product.uom_id.id, + "picking_id": return_picking.id, + "state": "draft", + "date": fields.Datetime.now(), + "location_id": return_picking.location_id.id, + "location_dest_id": return_picking.location_dest_id.id, + "picking_type_id": return_type.id, + "warehouse_id": return_type.warehouse_id.id, + "origin_returned_move_id": origin_move.id, + "procure_method": "make_to_stock", + } + + def _create_return_move__link_to_origin(self, return_move, origin_move): + move_orig_to_link = origin_move.move_dest_ids.mapped("returned_move_ids") + move_orig_to_link |= origin_move + origin_move_dest = origin_move.move_dest_ids.filtered( + lambda m: m.state not in ("cancel") + ) + move_orig_to_link |= origin_move_dest.move_orig_ids.filtered( + lambda m: m.state not in ("cancel") + ) + move_dest_to_link = origin_move.move_orig_ids.mapped("returned_move_ids") + move_dest_orig = origin_move.returned_move_ids.move_orig_ids.filtered( + lambda m: m.state not in ("cancel") + ) + move_dest_to_link |= move_dest_orig.move_dest_ids.filtered( + lambda m: m.state not in ("cancel") + ) + write_vals = { + "move_orig_ids": [(4, m.id) for m in move_orig_to_link], + "move_dest_ids": [(4, m.id) for m in move_dest_to_link], + } + return_move.write(write_vals) + + def create_return_move(self, return_picking, origin_moves): + """Creates a return move for a given return picking / move""" + # Logic has been copied from + # odoo_src/addons/stock/wizard/stock_picking_return.py + for origin_move in origin_moves: + # If max qty <= 0, it means that everything has been returned already. + # Try with the next one from the recordset. + max_qty = self._create_return_move__get_max_qty(origin_move) + if max_qty > 0: + return_move_vals = self._create_return_move__get_vals( + return_picking, origin_move + ) + return_move_vals.update(product_uom_qty=max_qty) + return_move = origin_move.copy(return_move_vals) + self._create_return_move__link_to_origin(return_move, origin_move) + return return_move + + def _create_return_picking__get_vals(self, return_types, origin): + return_type = fields.first(return_types) + return { + "move_lines": [], + "picking_type_id": return_type.id, + "state": "draft", + "origin": origin, + "location_id": return_type.default_location_src_id.id, + "location_dest_id": return_type.default_location_dest_id.id, + "is_shopfloor_created": True, + } + + def create_return_picking(self, picking, return_types, origin): + # Logic has been copied from + # odoo_src/addons/stock/wizard/stock_picking_return.py + return_values = self._create_return_picking__get_vals(return_types, origin) + return picking.copy(return_values) + + def mark_move_line_as_picked( + self, move_lines, quantity=None, package=None, user=None, check_user=False + ): + """Set the qty_done and extract lines in new order""" + user = user or self.env.user + if check_user: + picking_users = move_lines.picking_id.user_id + if not all(pick_user == user for pick_user in picking_users): + raise ConcurentWorkOnTransfer( + _("Someone is already working on these transfers") + ) + for line in move_lines: + qty_done = quantity if quantity is not None else line.reserved_uom_qty + line.qty_done = qty_done + line._split_partial_quantity() + data = { + "shopfloor_user_id": user.id, + } + if package: + # destination package is set to the scanned one + data["result_package_id"] = package.id + line.write(data) + # Extract the picked quantity in a split order and set current user + move_lines._extract_in_split_order( + { + "user_id": user.id, + "printed": True, + } + ) + move_lines.picking_id.filtered(lambda p: p.user_id != user).user_id = user.id + + def unmark_move_line_as_picked(self, move_lines): + """Reverse the change from `mark_move_line_as_picked`.""" + move_lines.write( + { + "shopfloor_user_id": False, + "qty_done": 0, + "result_package_id": False, + } + ) + pickings = move_lines.picking_id + for picking in pickings: + lines_still_assigned = picking.move_line_ids.filtered( + lambda l: l.shopfloor_user_id + ) + if lines_still_assigned: + # Because there is other lines in the picking still assigned + # The picking has to be split + unmark_lines = picking.move_line_ids & move_lines + unmark_lines._extract_in_split_order(default={"user_id": False}) + else: + pickings.write( + { + "user_id": False, + "printed": False, + } + ) + + def validate_moves(self, moves): + """Validate moves in different ways depending on several criterias: + + - moves to process are all the moves of the related transfer: + the current transfer is validated + - moves to process are a subset of available moves in the picking: + the moves are put in a new transfer which is validated, the current + transfer still have the remaining moves + - moves to process are exactly the assigned moves of the related transfer: + the transfer is validated as usual, creating a backorder. + """ + moves.split_unavailable_qty() + backorders = self.env["stock.picking"] + for picking in moves.picking_id: + # the backorder strategy is checked in the 'button_validate' method + # on odoo standard. Since we call the sub-method '_action_done' here, + # we have to set the context key 'cancel_backorder' as it is done + # in the 'button_validate' method according to the backorder strategy. + not_to_backorder = picking.picking_type_id.create_backorder == "never" + picking = picking.with_context(cancel_backorder=not_to_backorder) + moves_todo = picking.move_ids & moves + if self._check_backorder(picking, moves_todo): + existing_backorders = picking.backorder_ids + picking._action_done() + new_backorders = picking.backorder_ids - existing_backorders + if new_backorders: + new_backorders.write({"user_id": False}) + backorders |= new_backorders + else: + backorders |= moves_todo.extract_and_action_done() + return backorders + + def _check_backorder(self, picking, moves): + """Check if the `picking` has to be validated as usual to create a backorder. + + We want to create a normal backorder if: + + - the moves are equal to all available moves of the current picking + but there are still unavailable moves to process + - the moves are not linked to unprocessed ancestor moves + """ + assigned_moves = picking.move_ids.filtered(lambda m: m.state == "assigned") + has_ancestors = bool( + moves.move_orig_ids.filtered(lambda m: m.state not in ("cancel", "done")) + ) + return moves == assigned_moves and not has_ancestors + + def put_package_level_in_move(self, package_level): + """Ensure to put the package level in its own move. + + In standard the moves linked to a package level could also be linked to + other unrelated move lines. This method ensures that the package level + will be attached to a move with only the relevant lines. + This is useful to process a single package, having its own move makes + this process easy. + """ + package_move_lines = package_level.move_line_ids + package_moves = package_move_lines.move_id + for package_move in package_moves: + # Check if there is no other lines linked to the move others than + # the lines related to the package itself. In such case we have to + # split the move to process only the lines related to the package. + package_move.split_other_move_lines(package_move_lines) + + def no_putaway_available(self, picking_types, move_lines): + """Returns `True` if no putaway destination has been computed for one + of the given move lines. + """ + base_locations = picking_types.default_location_dest_id + # when no putaway is found, the move line destination stays the + # default's of the picking type + return any(line.location_dest_id in base_locations for line in move_lines) diff --git a/shopfloor/actions/stock_unreserve.py b/shopfloor/actions/stock_unreserve.py new file mode 100644 index 0000000000..b279a5d908 --- /dev/null +++ b/shopfloor/actions/stock_unreserve.py @@ -0,0 +1,66 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo.addons.component.core import Component + + +class StockUnreserve(Component): + """Provide methods to unreserve goods of a location.""" + + _name = "shopfloor.stock.unreserve.action" + _inherit = "shopfloor.process.action" + _usage = "stock.unreserve" + + def check_unreserve(self, location, move_lines, product=None, lot=None): + """Return a message if there is an ongoing operation in the location. + + It could be a move line with some qty already processed or another + Shopfloor user working there. + + :param location: stock location from which moves are unreserved + :param move_lines: move lines to unreserve + :param product: optional product to limit the scope in the location + """ + location_move_lines = self._find_location_all_move_lines(location, product, lot) + extra_move_lines = location_move_lines - move_lines + if extra_move_lines: + return self.msg_store.picking_already_started_in_location( + extra_move_lines.picking_id + ) + + def unreserve_moves(self, move_lines, picking_types): + """Unreserve moves from `move_lines'. + + Returns a tuple of ( + move lines that stays in the location to process, + moves to reserve again + ) + """ + moves_to_unreserve = move_lines.move_id + # If there is no other moves to unreserve of a different picking type, leave + lines_other_picking_types = move_lines.filtered( + lambda line: line.picking_id.picking_type_id not in picking_types + ) + if not lines_other_picking_types: + return (move_lines, self.env["stock.move"].browse()) + # if we leave the package level around, it will try to reserve + # the same package as before + package_levels = move_lines.package_level_id + package_levels.explode_package() + moves_to_unreserve._do_unreserve() + return (move_lines - lines_other_picking_types, moves_to_unreserve) + + def _find_location_all_move_lines_domain(self, location, product=None, lot=None): + domain = [ + ("location_id", "=", location.id), + ("state", "in", ("assigned", "partially_available")), + ] + if product: + domain.append(("product_id", "=", product.id)) + if lot: + domain.append(("lot_id", "=", lot.id)) + return domain + + def _find_location_all_move_lines(self, location, product=None, lot=None): + return self.env["stock.move.line"].search( + self._find_location_all_move_lines_domain(location, product, lot) + ) diff --git a/shopfloor/components/__init__.py b/shopfloor/components/__init__.py new file mode 100644 index 0000000000..dc9e3f1188 --- /dev/null +++ b/shopfloor/components/__init__.py @@ -0,0 +1,5 @@ +from . import scan_handler_location +from . import scan_handler_package +from . import scan_handler_product +from . import scan_handler_lot +from . import scan_handler_transfer diff --git a/shopfloor/components/scan_handler_location.py b/shopfloor/components/scan_handler_location.py new file mode 100644 index 0000000000..9ce7dc6173 --- /dev/null +++ b/shopfloor/components/scan_handler_location.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class LocationHandler(Component): + """Scan anything handler for stock.location.""" + + _name = "shopfloor.scan.location.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "location" + + def search(self, identifier): + res = self._search.find(identifier, types=("location",)) + return res.record if res.record else self.env["stock.location"] + + @property + def converter(self): + return self._data_detail.location_detail + + def schema(self): + return self._schema_detail.location_detail() diff --git a/shopfloor/components/scan_handler_lot.py b/shopfloor/components/scan_handler_lot.py new file mode 100644 index 0000000000..2b16f98cf9 --- /dev/null +++ b/shopfloor/components/scan_handler_lot.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class LotHandler(Component): + """Scan anything handler for stock.lot.""" + + _name = "shopfloor.scan.lot.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "lot" + + def search(self, identifier): + res = self._search.find(identifier, types=("lot",)) + return res.record if res.record else self.env["stock.lot"] + + @property + def converter(self): + return self._data_detail.lot_detail + + def schema(self): + return self._schema_detail.lot_detail() diff --git a/shopfloor/components/scan_handler_package.py b/shopfloor/components/scan_handler_package.py new file mode 100644 index 0000000000..27da7a3c82 --- /dev/null +++ b/shopfloor/components/scan_handler_package.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class PackageHandler(Component): + """Scan anything handler for stock.quant.package.""" + + _name = "shopfloor.scan.package.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "package" + + def search(self, identifier): + res = self._search.find(identifier, types=("package",)) + return res.record if res.record else self.env["stock.quant.package"] + + @property + def converter(self): + return self._data_detail.package_detail + + def schema(self): + return self._schema_detail.package_detail() diff --git a/shopfloor/components/scan_handler_product.py b/shopfloor/components/scan_handler_product.py new file mode 100644 index 0000000000..1aa08fefa4 --- /dev/null +++ b/shopfloor/components/scan_handler_product.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class ProductHandler(Component): + """Scan anything handler for product.product.""" + + _name = "shopfloor.scan.product.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "product" + + def search(self, identifier): + res = self._search.find(identifier, types=("product", "packaging")) + return res.record if res.record else self.env["product.product"] + + @property + def converter(self): + return self._data_detail.product_detail + + def schema(self): + return self._schema_detail.product_detail() diff --git a/shopfloor/components/scan_handler_transfer.py b/shopfloor/components/scan_handler_transfer.py new file mode 100644 index 0000000000..8e1ddc6527 --- /dev/null +++ b/shopfloor/components/scan_handler_transfer.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.component.core import Component + + +class TransferHandler(Component): + """Scan anything handler for stock.picking.""" + + _name = "shopfloor.scan.transfer.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "transfer" + + def search(self, identifier): + res = self._search.find(identifier, types=("picking",)) + return res.record if res.record else self.env["stock.picking"] + + @property + def converter(self): + return self._data_detail.picking_detail + + def schema(self): + return self._schema_detail.picking_detail() diff --git a/shopfloor/data/shopfloor_scenario_data.xml b/shopfloor/data/shopfloor_scenario_data.xml new file mode 100644 index 0000000000..c26c67c156 --- /dev/null +++ b/shopfloor/data/shopfloor_scenario_data.xml @@ -0,0 +1,73 @@ + + + Single Pack Transfer + single_pack_transfer + +{ + "allow_create_moves": true, + "allow_unreserve_other_moves": true, + "allow_ignore_no_putaway_available": true, + "must_move_entire_pack": true +} + + + + Zone Picking + zone_picking + +{ + "pick_pack_same_time": true, + "unload_package_at_destination": true, + "multiple_move_single_pack": true, + "no_prefill_qty": true, + "scan_location_or_pack_first": true +} + + + + Cluster Picking + cluster_picking + +{ + "unload_package_at_destination": true, + "multiple_move_single_pack": true, + "no_prefill_qty": true, + "scan_location_or_pack_first": true +} + + + + Checkout + checkout + +{ + "no_prefill_qty": true, + "show_oneline_package_content": true, + "auto_post_line": true +} + + + + Delivery + delivery + +{ + "must_move_entire_pack": true, + "allow_prepackaged_product": true +} + + + + Location content transfer + location_content_transfer + +{ + "allow_create_moves": true, + "allow_unreserve_other_moves": true, + "allow_ignore_no_putaway_available": true, + "allow_get_work": true, + "no_prefill_qty": true +} + + + diff --git a/shopfloor/demo/shopfloor_app_demo.xml b/shopfloor/demo/shopfloor_app_demo.xml new file mode 100644 index 0000000000..d5651d0753 --- /dev/null +++ b/shopfloor/demo/shopfloor_app_demo.xml @@ -0,0 +1,12 @@ + + + Shopfloor WMS (demo) + WMS (demo) + wms_demo + wms + + + diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml new file mode 100644 index 0000000000..2f6e57c411 --- /dev/null +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -0,0 +1,64 @@ + + + Single Pallet Transfer + 20 + + + + + + + Zone Picking + 35 + + + + + + Cluster Picking + 30 + + + + + + Checkout + 40 + + + + + + Delivery + 50 + + + + + + Location Content Transfer + 60 + + + + + + diff --git a/shopfloor/demo/shopfloor_profile_demo.xml b/shopfloor/demo/shopfloor_profile_demo.xml new file mode 100644 index 0000000000..770c5881df --- /dev/null +++ b/shopfloor/demo/shopfloor_profile_demo.xml @@ -0,0 +1,8 @@ + + + WH worker + + + WH delivery + + diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml new file mode 100644 index 0000000000..c912a1087d --- /dev/null +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -0,0 +1,93 @@ + + + Single Pallet Transfer + SPT + + + + + + + + + internal + + + + + Zone Picking + ZPI + + + + + + + + + internal + + + + + Cluster Picking + CPI + + + + + + + + + internal + + + + + + Checkout + CHK + + + + + + + + + internal + + + + + Delivery + DEL + + + + + + + + + internal + + + + + Location Content Transfer + LCT + + + + + + + + + internal + + + + diff --git a/shopfloor/docs/checkout_diag_seq.plantuml b/shopfloor/docs/checkout_diag_seq.plantuml new file mode 100644 index 0000000000..690cba6827 --- /dev/null +++ b/shopfloor/docs/checkout_diag_seq.plantuml @@ -0,0 +1,61 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml delivery_diag_seq.plantuml +# + +@startuml + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Checkout scenario + +== /list_stock_picking == +select_document -> manual_selection: **/list_stock_picking** + +== /select == +manual_selection -> select_line: **/select**(picking_id) + +== /scan_document == +select_document -> select_line: **/scan_document**(barcode, picking_id=None) \n(scan a picking a product or ???) + +== /select_line == +select_line -> select_package: **/select_line**(picking_id, package_id, move_line_ids) + +== /list_delivery_packaging == +select_package -> select_delivery_packaging: **/list_delivery_packaging**(picking_id, selected_line_ids) + + +== /list_dest_package == +select_package -> select_dest_package: **/list_dest_package**(picking_id, selected_line_ids) +select_package -> select_package: **/list_dest_package**(picking_id, selected_line_ids) \n No Valid package to select + +== /set_dest_package == +select_dest_package -> select_line: **/set_dest_package(picking_id, selected_line_ids, package_id) + +== /scan_package_action == +select_delivery_packaging -> summary: **/scan_package_action**(picking_id, selected_line_ids, barcode) + +== /cancel_line == +summary -> select_line: **/cancel_line**(picking_id, package_id=None, line_id=None) + + == /select == + summary -> select_line: **/select**(picking_id) \nContinue checkout + +== /done == +summary -> select_document: **/done(picking_id)** + + +@enduml diff --git a/shopfloor/docs/checkout_diag_seq.png b/shopfloor/docs/checkout_diag_seq.png new file mode 100644 index 0000000000..4a0c0d7ce2 Binary files /dev/null and b/shopfloor/docs/checkout_diag_seq.png differ diff --git a/shopfloor/docs/cluster_picking_diag_seq.plantuml b/shopfloor/docs/cluster_picking_diag_seq.plantuml new file mode 100644 index 0000000000..71763b2b45 --- /dev/null +++ b/shopfloor/docs/cluster_picking_diag_seq.plantuml @@ -0,0 +1,112 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml cluster_picking_diag_seq.plantuml +# + +@startuml +participant start +participant manual_selection +participant confirm_start + +participant start_line +participant scan_destination +participant zero_check +participant stock_issue +participant change_pack_lot + +participant unload_all +participant confirm_unload_all +participant unload_single +participant unload_set_destination +participant confirm_unload_set_destination + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Cluster Picking scenario + + +== Batch Transfer Selection == +start -[#red]> start: **/find_batch** \n(error) +start -> confirm_start: **/find_batch** + +start -> manual_selection: **/list_batch** + +manual_selection -[#red]> manual_selection: **/select(picking_batch_id)** \n(error) +manual_selection -> confirm_start: **/select(picking_batch_id)** +manual_selection -> start: Button **Back** (client-side) + +confirm_start -> start_line: **/confirm_start(picking_batch_id)** +confirm_start -> unload_all: **/confirm_start(picking_batch_id)** \n(we reopen a batch with all lines picked and have to be unloaded in the same destination) +confirm_start -> unload_single: **/confirm_start(picking_batch_id)** \n(we reopen a batch with all lines picked and have to be unloaded in different destinations) +confirm_start -> start: **/unassign(picking_batch_id)** + +== Picking == + +start_line -[#red]> start_line: **/scan_line(picking_batch_id, move_line_id, barcode[package|product|lot])** \n(error) +start_line -> scan_destination: **/scan_line(picking_batch_id, move_line_id, barcode[package|product|lot])** \n(error) + +scan_destination -[#red]> scan_destination: **/scan_destination_pack(picking_batch_id, move_line_id, barcode[package], quantity)** \n(error) +scan_destination -> start_line: **/scan_destination_pack(picking_batch_id, move_line_id, barcode[package], quantity)** \n(other lines to pick) +scan_destination -> zero_check: **/scan_destination_pack(picking_batch_id, move_line_id, barcode[package], quantity)** \n(source location is now empty) +scan_destination -> unload_all: **/scan_destination_pack(picking_batch_id, move_line_id, barcode[package], quantity)** \n(all lines picked and same destination) +scan_destination -> unload_single: **/scan_destination_pack(picking_batch_id, move_line_id, barcode[package], quantity)** \n(all lines picked and different destinations) + +start_line -> unload_all: **/prepare_unload(picking_batch_id)** \n(all lines picked and same destination) +start_line -> unload_single: **/prepare_unload(picking_batch_id)** \n(all lines picked and different destinations) + +start_line -> start_line: **/skip_line(picking_batch_id, move_line_id)** + +start_line -> stock_issue: Button *Stock Issue* (client-side) +stock_issue -> start_line: **/stock_issue(picking_batch_id, move_line_id)** \n(other lines to pick) +stock_issue -> unload_all: **/stock_issue(picking_batch_id, move_line_id)** \n(all lines picked and same destination) +stock_issue -> unload_single: **/stock_issue(picking_batch_id, move_line_id)** \n(all lines picked and different destinations) + +zero_check -> start_line: **/is_zero(picking_batch_id, move_line_id, zero[bool])** \n(other lines to pick) +zero_check -> unload_all: **/is_zero(picking_batch_id, move_line_id, zero[bool])** \n(all lines picked and same destination) +zero_check -> unload_single: **/is_zero(picking_batch_id, move_line_id, zero[bool])** \n(all lines picked and different destinations) + +start_line -> change_pack_lot: Button *Change Package/Lot* (client-side) +change_pack_lot -[#red]> change_pack_lot: **/change_pack_lot(picking_batch_id, move_line_id, barcode[package|lot])** \n(error) +change_pack_lot -> scan_destination: **/change_pack_lot(picking_batch_id, move_line_id, barcode[package|lot])** + +== Unloading == + +unload_all -[#red]> unload_all: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(error) +unload_all -> start_line: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(unloaded, batch contains other lines to pick) +unload_all -> unload_single: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(lines have different destinations after all) +unload_all -> confirm_unload_all: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(change of destination to confirm) +unload_all -> start: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(batch finished) + +confirm_unload_all -[#red]> unload_all: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(error) +confirm_unload_all -> start_line: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(unloaded, batch contains other lines to pick) +confirm_unload_all -> unload_single: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(lines have different destinations after all) +confirm_unload_all -> start: **/set_destination_all(picking_batch_id, barcode[location], confirmation=False)** \n(batch finished) + +unload_all -> unload_single: **/unload_split(picking_batch_id)** + +unload_single -[#red]> unload_single: **/unload_scan_pack(picking_batch_id, package_id, barcode[location])** \n(error) +unload_single -> start_line: **/unload_scan_pack(picking_batch_id, package_id, barcode[location])** \n(package not found and still have lines to pick) +unload_single -> unload_set_destination: **/unload_scan_pack(picking_batch_id, package_id, barcode[location])** \n(scan is ok, has to set a destination) + +unload_set_destination -[#red]> unload_single: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(error) +unload_set_destination -> confirm_unload_set_destination: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(change of destination needs confirmation) +unload_set_destination -> start_line: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(batch has other lines to pick) +unload_set_destination -> start: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(batch finished) +confirm_unload_set_destination -[#red]> unload_single: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(error) +confirm_unload_set_destination -> start_line: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(batch has other lines to pick) +confirm_unload_set_destination -> start: **/unload_scan_destination(picking_batch_id, package_id, barcode[location], confirmation=False)** \n(batch finished) + +@enduml diff --git a/shopfloor/docs/cluster_picking_diag_seq.png b/shopfloor/docs/cluster_picking_diag_seq.png new file mode 100644 index 0000000000..64b85bd198 Binary files /dev/null and b/shopfloor/docs/cluster_picking_diag_seq.png differ diff --git a/shopfloor/docs/delivery_diag_seq.plantuml b/shopfloor/docs/delivery_diag_seq.plantuml new file mode 100644 index 0000000000..5bb786c0f8 --- /dev/null +++ b/shopfloor/docs/delivery_diag_seq.plantuml @@ -0,0 +1,56 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml delivery_diag_seq.plantuml +# + +@startuml + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Delivery scenario + +== /list_stock_picking == +deliver -> manual_selection: **/list_stock_picking** + +== /select == +manual_selection -> deliver: **/select**(picking_id) + +== /scan_deliver == +deliver -> deliver: **/scan_deliver**(barcode, picking_id=None) \n(scan a picking to display its lines, or a package/lot/product to process related lines,\nwhen all the available move lines of the transfer are done, the stock picking is set to done) + +== /set_qty_done_pack == + +deliver -> deliver: **/set_qty_done_pack(picking_id, package_id)** \n(when all the available move lines of the transfer are done, the transfer is set to done.) + +== /set_qty_done_line == + +deliver -> deliver: **/set_qty_done_line(picking_id, move_line_id)** \n(when all the available move lines of the transfer are done, the transfer is set to done.) + +== /reset_qty_done_pack == + +deliver -> deliver: **/reset_qty_done_pack(picking_id, package_id)** \n(remove "Done" on a package) + +== /reset_qty_done_line == + +deliver -> deliver: **/reset_qty_done_line(picking_id, move_line_id)** \n(remove "Done" on a move line) + +== /done == + +deliver -> deliver: **/done(picking_id)** \n(all lines processed) +deliver -> confirm_done: **/done(picking_id)** \n(lines partially processed, need confirmation) +confirm_done -> deliver: **/done(picking_id, confirm=True)** + +@enduml diff --git a/shopfloor/docs/delivery_diag_seq.png b/shopfloor/docs/delivery_diag_seq.png new file mode 100644 index 0000000000..44647b6b85 Binary files /dev/null and b/shopfloor/docs/delivery_diag_seq.png differ diff --git a/shopfloor/docs/location_content_transfer_diag_seq.plantuml b/shopfloor/docs/location_content_transfer_diag_seq.plantuml new file mode 100644 index 0000000000..d37fbb3c0e --- /dev/null +++ b/shopfloor/docs/location_content_transfer_diag_seq.plantuml @@ -0,0 +1,66 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml location_content_transfer_diag_seq.plantuml +# + +@startuml + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Location Content Transfer scenario + +start_or_recover -> get_work : `get_work` option is enabled +start_or_recover -> scan_location : `get_work` option is not enabled +start_or_recover -> scan_destination_all : existing work to resume for the user +start_or_recover -> start_single : existing work to resume for the user + +== /find_work == +get_work -> scan_location: **/find_work** (work found) +get_work -> get_work: **/find_work** (no work available) +get_work -> scan_location: click on `Manual Selection` button + +== /cancel_work == +scan_location -> get_work: **/cancel_work(location_id)** \n(Only available coming from `get_work`) + +== /scan_location == +scan_location -> scan_location: **/scan_location(barcode)** \n(Location not found) +scan_location -> scan_destination_all: **/scan_location(barcode)** \n(lines and package levels have the same destination) +scan_location -> start_single: **/scan_location(barcode)** \n(lines or package levels have different destination) + +== /scan_package == +start_single -> start_single: **/scan_package(barcode)** \n(barcode not found) +start_single -> scan_destination: **/scan_package(barcode)** + +== /postpone_package == +start_single -> start_single: **/postpone_package(location_id, package_level_id)** + +== /dismiss_package_level == +start_single -> start_single: **/dismiss_package_level(location_id, package_level_id)** + +== /stock_out_line == +start_single -> start_single: **/dismiss_package_level(location_id, move_line_id)** \n(continue with next line or package level) +start_single -> done: **/dismiss_package_level(location_id, move_line_id)** \n(no more content to move) + +== /set_destination_all == +scan_destination_all -> done: **/set_destination_all(location_id)** +scan_destination_all -> scan_destination_all: **/set_destination_all(location_id)** \n(Invalid destination) + +== /go_to_single == +scan_destination_all -> start_single: **/go_to_single(location_id)** + +done -> start_or_recover: + +@enduml diff --git a/shopfloor/docs/location_content_transfer_diag_seq.png b/shopfloor/docs/location_content_transfer_diag_seq.png new file mode 100644 index 0000000000..6c3ee68395 Binary files /dev/null and b/shopfloor/docs/location_content_transfer_diag_seq.png differ diff --git a/shopfloor/docs/oca_logo.png b/shopfloor/docs/oca_logo.png new file mode 100644 index 0000000000..84f216c294 Binary files /dev/null and b/shopfloor/docs/oca_logo.png differ diff --git a/shopfloor/docs/single_pack_transfer_diag_seq.plantuml b/shopfloor/docs/single_pack_transfer_diag_seq.plantuml new file mode 100644 index 0000000000..35865182d4 --- /dev/null +++ b/shopfloor/docs/single_pack_transfer_diag_seq.plantuml @@ -0,0 +1,36 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml single_pack_transfer_diag_seq.plantuml +# + +@startuml + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Single Pack Transfer scenario + +== /start == +start -> scan_location: **/start**(barcode[pack|location], confirmation=False) +start -> start: **/start**(barcode[pack|location], confirmation=False) + +== /cancel == +scan_location -> start: **/cancel**(package_level_id) + +== /validate == +scan_location -> scan_location: **/validate**(package_level_id, location_barcode, confirmation=False) +scan_location -> start: **/validate**(package_level_id, location_barcode, confirmation=False) + +@enduml diff --git a/shopfloor/docs/single_pack_transfer_diag_seq.png b/shopfloor/docs/single_pack_transfer_diag_seq.png new file mode 100644 index 0000000000..60b3c92024 Binary files /dev/null and b/shopfloor/docs/single_pack_transfer_diag_seq.png differ diff --git a/shopfloor/docs/zone_picking_diag_seq.plantuml b/shopfloor/docs/zone_picking_diag_seq.plantuml new file mode 100644 index 0000000000..adc75b8f05 --- /dev/null +++ b/shopfloor/docs/zone_picking_diag_seq.plantuml @@ -0,0 +1,85 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml zone_picking_diag_seq.plantuml +# + +@startuml + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Zone picking scenario + +== /select_zone == +[-> start: **/select_zone** + +== /scan_location == +start -> select_picking_type: **/scan_location** + +== /list_move_lines == +select_picking_type -> select_line: **/list_move_lines** + +== /scan_source == +select_line -> select_line: **/scan_source** \n(scanned package not expected but valid, confirmation required) +select_line -> set_line_destination: **/scan_source** + +== /set_destination == +set_line_destination -[#red]> set_line_destination: **/set_destination** \n(error) +set_line_destination -> set_line_destination: **/set_destination** \n(scanned location not expected but valid, confirmation required) +set_line_destination -> select_line: **/set_destination** +set_line_destination -> zero_check: **/set_destination** +zero_check -> select_line: **/is_zero** + +== /prepare_unload == +select_line -> unload_single: **/prepare_unload** \n(different destinations in buffer lines, process one by one) +select_line -> unload_set_destination: **/prepare_unload** \n(only one move line in the buffer) +select_line -> unload_all: **/prepare_unload** \n(buffer lines have all the same destination location) +select_line -> select_line: **/prepare_unload** \n(no remaining lines in the buffer) +start -> unload_all : **/prepare_unload** \n On Unload To Destination button click (any unload endpoint) + +== /set_destination_all == +unload_all -[#red]> unload_all: **/set_destination_all** \n(error, scanned destination location invalid) +unload_all -> unload_all: **/set_destination_all** \n(scanned destination not expected but valid, confirmation required) +unload_all -> select_line: **/set_destination_all** \n(move lines still need to be processed) +unload_all -> start: **/set_destination_all** \n(all move lines have been processed) + +== /unload_split == +select_line -> unload_single: **/unload_split** +select_line -> unload_set_destination: **/unload_split** +select_line -> select_line: **/unload_split** + +== /unload_scan_pack == +unload_single -[#red]> unload_single: **/unload_scan_pack** \n(error) +unload_single -> unload_set_destination: **/unload_scan_pack** +unload_single -> select_line: **/unload_scan_pack** +unload_single -> start: **/unload_scan_pack** + +== /unload_set_destination == +unload_set_destination -> unload_single: **/unload_set_destination** +unload_set_destination -[#red]> unload_set_destination: **/unload_set_destination** \n(error) +unload_set_destination -> select_line: **/unload_set_destination** +unload_set_destination -> start: **/unload_set_destination** + +== /stock_issue == +set_line_destination -> stock_issue: Button **"stock out"** (client-side) +stock_issue -> set_line_destination: **/stock_issue** \n(goods are available after the inventory) +stock_issue -> select_line: **/stock_issue** \n(goods still not available) + +== /change_pack_lot == +set_line_destination -> change_pack_lot: Button "Change pack or lot" (client-side) +change_pack_lot -> set_line_destination: **/change_pack_lot** \n(pack/lot has been changed on the line) +change_pack_lot -[#red]> change_pack_lot: **/change_pack_lot** \n(error, unable to change pack/lot on the line) + +@enduml diff --git a/shopfloor/docs/zone_picking_diag_seq.png b/shopfloor/docs/zone_picking_diag_seq.png new file mode 100644 index 0000000000..09fe8f6cd7 Binary files /dev/null and b/shopfloor/docs/zone_picking_diag_seq.png differ diff --git a/shopfloor/exceptions.py b/shopfloor/exceptions.py new file mode 100644 index 0000000000..14bb2ecbf9 --- /dev/null +++ b/shopfloor/exceptions.py @@ -0,0 +1,6 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + + +class ConcurentWorkOnTransfer(Exception): + """Some user already processed some transfers.""" diff --git a/shopfloor/i18n/ca.po b/shopfloor/i18n/ca.po new file mode 100644 index 0000000000..3ce6a599bf --- /dev/null +++ b/shopfloor/i18n/ca.po @@ -0,0 +1,1802 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2021-06-04 10:48+0000\n" +"Last-Translator: jabelchi \n" +"Language-Team: none\n" +"Language: ca\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.3.2\n" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "" +"\n" +"If you tick this box, while picking goods from a location\n" +"(eg: zone picking) set destination will work as follow:\n" +"\n" +"* if a location is scanned, a new delivery package is created;\n" +"* if a package is scanned, the package is validated against the carrier\n" +"* in both cases, if the picking has no carrier the operation fails.\",\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__no_prefill_qty +msgid "" +"\n" +"We assume the picker will take the suggested quantities.\n" +"With this option, the operator will have to enter the quantity manually or\n" +"by scanning a product or product packaging EAN to increase the quantity\n" +"(i.e. +1 Unit or +1 Box)\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_return +msgid "" +"\n" +"When enabled, you can receive unplanned products that are returned\n" +"from an existing delivery matched on the origin (SO name).\n" +"A new move will be added as a return of the delivery,\n" +"decreasing the delivered quantity of the related SO line.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__multiple_move_single_pack +msgid "" +"\n" +"When picking a move,\n" +"allow to set a destination package that was already used for the other " +"lines.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__auto_post_line +msgid "" +"\n" +"When setting result pack & destination,\n" +"automatically post the corresponding line\n" +"if this option is checked.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__unload_package_at_destination +msgid "" +"\n" +"With this option, the lines you process by putting on a package during the\n" +"picking process will be put as bulk products at the final destination " +"location.\n" +"\n" +"This is useful if your picking device is emptied at the destination location " +"or\n" +"if you want to provide bulk products to the next operation.\n" +"\n" +"Incompatible with: \"Pick and pack at the same time\"\n" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"'Pick and pack at the same time' is incompatible with 'Multiple moves same " +"destination package'." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"'Pick and pack at the same time' is incompatible with 'Unload package at " +"destination'." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_app +msgid "A Shopfloor application" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "A destination package is required." +msgstr "Es requereix un paquet destí." + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "A draft inventory has been created for control." +msgstr "S'ha creat un inventari esborrany per a control." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "Activate Zero Check" +msgstr "Activar verificació zero" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin +msgid "Adds shopfloor priority/postpone fields" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "All packages processed." +msgstr "Processats tots els paquets." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_alternative_destination_is_possible +msgid "Allow Alternative Destination Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation_is_possible +msgid "Allow Force Reservation Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_get_work_is_possible +msgid "Allow Get Work Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create +msgid "Allow Move Creation" +msgstr "Permetre creació de moviment" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_return_is_possible +msgid "Allow Return Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_return +msgid "Allow create returns" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "Allow to process reserved quantities" +msgstr "Permetre processar quantitats reservades" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_alternative_destination +msgid "Allow to scan alternative destination locations" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Are you sure?" +msgstr "Esteu segur?" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__auto_post_line_is_possible +msgid "Auto Post Line Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__auto_post_line +msgid "Automatically post line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode does not match with {}." +msgstr "Codi de barres no coincideix amb {}." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode not found" +msgstr "Codi de barres no trobat" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_batch +msgid "Batch Transfer" +msgstr "Transferència per lots" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer complete" +msgstr "Transferència per lots completa" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer line done" +msgstr "Línia de transferència per lots completada" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Bin %s doesn't exist" +msgstr "Compartiment %s no existeix" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__bulk_line_count +msgid "Bulk Line Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Canceled, you can scan a new pack." +msgstr "Cancel·lat. No es pot escanejar un nou paquet." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Cannot change to lot {} which is entirely picked." +msgstr "No es pot canviar al lot {} que ya està recollit per complet." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__category +msgid "Category" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_checkout +#: model:shopfloor.scenario,name:shopfloor.scenario_checkout +#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo +msgid "Checkout" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_cluster_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_cluster_picking +#: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo +msgid "Cluster Picking" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__multiple_move_single_pack +msgid "Collect multiple moves on a same destination package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Confirm location change from %s to %s?" +msgstr "Confirmeu canvi d'ubicació de %s a %s?" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transfer to {} completed" +msgstr "Transferència de contingut a {} competada" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transferred from %(location_name)s to %(location_dest_name)s." +msgstr "Contingut transferit de {} a {}." + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Control stock issue in location {} for {}" +msgstr "Error de control d'estoc a la ubicació {} a {}" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Create new PACK {}? Scan it again to confirm." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Creation of moves is not allowed for menu {}." +msgstr "La creació de moviments no està permesa pel menú {}." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__date_planned +msgid "Date Scheduled" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_delivery +#: model:shopfloor.scenario,name:shopfloor.scenario_delivery +#: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo +msgid "Delivery" +msgstr "Lliurament" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Delivery package type scanned: %(name)s. Scan again to place all goods in " +"the same package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__disable_full_bin_action_is_possible +msgid "Disable Full Bin Action Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__disable_full_bin_action +msgid "Disable full bin action" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__display_name +msgid "Display Name" +msgstr "Nom a mostrar" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__show_oneline_package_content +msgid "Display the content of package if it contains 1 line only" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__no_prefill_qty +msgid "Do not pre-fill quantity to pick" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "" +"For Shopfloor scenarios using it (Cluster Picking, Zone Picking, Discrete " +"order Picking), the zero check step will be activated when a location " +"becomes empty after a move." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation +msgid "Force stock reservation" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id +msgid "From" +msgstr "Des de" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Goods packed into {0.name}" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__id +msgid "ID" +msgstr "ID" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "" +"If you tick this box, the transfer is reserved only if the put-away can find " +"a sublocation (when putaway destination is different from the operation " +"type's destination)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "" +"If you tick this box, this scenario will allow operator to move goods even " +"if a reservation is made by a different operation type." +msgstr "" +"Si marqueu aquesta casella, aquesta configuració permetrà al operador moure " +"mercaderies fins i tot si hi ha una reserva d'un altre tipus d'operació." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible +msgid "Ignore No Putaway Available Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "Ignore transfers when no put-away is available" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Ignoring not found putaway is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_inventory +msgid "Inventory" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_location +msgid "Inventory Locations" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_location____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/completion_info.py:0 +#, python-format +msgid "Last operation of transfer %(picking_names)s. Next operation (%(next_picking_names)s) is ready to " +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Line cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lines have different destination location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location %s doesn't contain any package." +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_location_content_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo +msgid "Location Content Transfer" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_location_content_transfer +msgid "Location content transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location empty. Try scanning a package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location not allowed here." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location {} empty" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Lot is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Lot {lot} for product {product} found in multiple locations. Scan your " +"location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Lot {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot {} is for another product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot %(old_lot_name)s replaced by lot %(new_lot_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Lot: " +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Missing expiration date." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible +msgid "Move Create Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids +msgid "Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count +msgid "Move Line Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Move already processed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Move lines processed have to share the same source location." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__multiple_move_single_pack_is_possible +msgid "Multiple Move Single Pack Is Possible" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Negative quantity not allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "New move lines cannot be assigned: canceled." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__no_prefill_qty_is_possible +msgid "No Prefill Qty Is Possible" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No available work could be found." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No delivery package type available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No line to pack found." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lines to process." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No location found for this barcode." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lot found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "No more work to do, please create a new batch transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation type found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/service.py:0 +#, python-format +msgid "No operation types configured on menu {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No package or lot found for barcode {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No pending operation for package %s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found in {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No putaway destination is available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No quantity has been processed, unable to complete the transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for the scanned lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for the scanned packaging." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for this lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for this product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No valid package to select." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No value" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Not all lines have been processed with full quantity. Do you confirm partial " +"operation?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids +msgid "Operation Types" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation already processed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Operation types for this menu are missing default source and destination " +"locations." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation's already running. Would you like to take it over?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__package_id +msgid "Package" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__package_level_count +msgid "Package Level Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package has been opened. You can move partial quantities." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Package level has to be in draft" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_quant_package.py:0 +#, python-format +msgid "Package name must be unique!" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Package {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s cannot be picked, already moved by transfer %(picking_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s cannot be used: %(error)s" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "" +"Package {} does not contain available product {}, cannot replace package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} has a different content." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Package {} has been partially picked in another location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is already used." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s is not available in transfer %(picking_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is not empty." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package {} is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(old_package_name)s replaced by package %(new_package_name)s." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant_package +msgid "Packages" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging '%(package_name)s' is not allowed for carrier %(carrier_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Packaging changed on package {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging not found in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Pick + Pack mode ON: the picking {0.name} has no carrier set. The system " +"couldn't pack goods automatically." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time_is_possible +msgid "Pick Pack Same Time Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "Pick and pack at the same time" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "Pick: stock issue on lot: %(lot_name)s found in %(location_name)s" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__picking_id +msgid "Picking" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count +msgid "Picking Count" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_type +msgid "Picking Type" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking has already been started in this location in transfer(s): {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking type {} complete." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Place it in {}?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Planned Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Please note that the scanned quantity is higher than the maximum allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Please scan the location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Please scan the package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__prepackaged_product_is_possible +msgid "Prepackaged Product Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_prepackaged_product +msgid "Process as pre-packaged" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Processing reserved quantities is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product tracked by lot, please scan one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Product {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product {} found in multiple locations. Scan your location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product(s) processed as raw product(s)" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant +msgid "Quants" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Real pack weight or the estimated one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Recovered previous session." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Remaining raw product not packed, proceed anyway?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids +msgid "Reserved Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Restart the operation, someone has canceled it." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF Priority" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF User" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF checkout done" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF unloaded" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Same package {} is already assigned." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scan_location_or_pack_first_is_possible +msgid "Scan Location Or Pack First Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scan_location_or_pack_first +msgid "Scan first location or pack" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Scan the destination location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Scan the package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"Scenario `%(scenario_name)s` require(s) 'Move Entire Packages' to be enabled.\n" +"These type(s) do not satisfy this constraint: \n" +"%(bad_picking_types)s.\n" +"Please, adjust your configuration." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__date_planned +msgid "Scheduled date until move is done, then date of actual move processing" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan a lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan the lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several moves found for different lots, please scan the lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Several moves found on different locations, please scan first the location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several operation types found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several packages found in %s, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan a product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan the product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "" +"Several transfers found, please scan a package or select a transfer manually." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several transfers found, please select a transfer manually." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form +#: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form +msgid "Shopfloor" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done +msgid "Shopfloor Checkout Done" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids +msgid "Shopfloor Menus" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "Shopfloor Picking Sequence" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "Shopfloor Postponed" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Shopfloor Priority" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded +msgid "Shopfloor Unloaded" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id +msgid "Shopfloor User" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.app,name:shopfloor.app_demo +msgid "Shopfloor WMS (demo)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Shopfloor weight (kg)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_get_work +msgid "Show Get Work on start" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__show_oneline_package_content_is_possible +msgid "Show Oneline Package Content Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__show_oneline_package_content +msgid "Show one-line package content" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_single_pack_transfer +msgid "Single Pack Transfer" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_single_pallet_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo +msgid "Single Pallet Transfer" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create +msgid "" +"Some scenario may create move(s) when a product or package is scanned and no " +"move already exists. Any new move is created in the selected operation type, " +"so it can be active only when one type is selected." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/stock.py:0 +#, python-format +msgid "Someone is already working on these transfers" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids +msgid "Source Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id +msgid "Source Package" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_package_level +msgid "Stock Package Level" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "" +"Technical field. Indicates if the operation has been postponed in a barcode " +"scenario." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count +msgid "Technical field. Indicates number of move lines included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__bulk_line_count +msgid "" +"Technical field. Indicates number of move lines without package included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__package_level_count +msgid "Technical field. Indicates number of package_level included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count +msgid "Technical field. Indicates number of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight +msgid "Technical field. Indicates total weight of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Technical field. Move lines for which destination is this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids +msgid "Technical field. Move lines moving this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Technical field. Overrides operation priority in barcode scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_picking.py:0 +#, python-format +msgid "" +"The backorder %s has been created." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The content of {} cannot be transferred with this scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "The destination bin {} is not empty, please take another." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The pack has been moved, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s cannot be transferred with this scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't contain any product to take." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The picked quantity must be a value above zero." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "" +"The picking done in Shopfloor scenarios will respect this order. The " +"sequence is a char so it can be composed of fields such as 'corridor-rack-" +"side-level'. Pay attention to the padding ('09' is before '19', '9' is not). " +"It is recommended to use an Export then an Import to populate this field " +"using a spreadsheet." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The product/packaging you selected has already been returned." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"The quantity scanned for one or more lines cannot be higher than the maximum " +"allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The record you were working on does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move.py:0 +#, python-format +msgid "The split order {} has been created." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id +msgid "The stock operation where the packing has been made" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "This batch cannot be selected." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line has a package, please select the package instead." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line is not available in transfer {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "This location content can't be moved at once." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location does not exist." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location only contains packages, please scan one of them." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location requires packages. Please scan a destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This lot is part of a package with other products, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This lot is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This operation does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This package does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This product is part of a package with other products, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of a package, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This source document is part of multiple transfers, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This transfer does not exist or is not available anymore." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight +msgid "Total Weight" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id +msgid "Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} is not available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Units replaced by package {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unload_package_at_destination_is_possible +msgid "Unload Package At Destination Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unload_package_at_destination +msgid "Unload package at destination" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Unrecoverable error, please restart." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible +msgid "Unreserve Other Moves Is Possible" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.profile,name:shopfloor.profile_demo_2 +msgid "WH delivery" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.profile,name:shopfloor.profile_demo_1 +msgid "WH worker" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_app__category__wms +msgid "WMS" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.app,short_name:shopfloor.app_demo +msgid "WMS (demo)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_prepackaged_product +msgid "" +"When active, what you scan (typically a product packaging EAN) will be ship " +"'as-is' and the operation will be validated triggering a backorder creation " +"with the remaining lines." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_get_work +msgid "" +"When enabled the user will have the option to ask for a task to work on." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_alternative_destination +msgid "" +"When enabled the user will have the option to scan destination locations " +"other than the expected ones (ask for confirmation)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__disable_full_bin_action +msgid "When picking, prevent unloading the whole bin when full." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__scan_location_or_pack_first +msgid "" +"When selecting work, force the user to first scan a location or pack,then " +"the product or lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Working location changed to {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "Wrong bin" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong packaging." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot move this using this menu." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot place it here" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot return more quantity than what was initially sent." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot work on a package (%s) outside of locations: %s" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You must not pick more than {} units." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"You scanned a different package with the same product, do you want to change " +"pack? Scan it again to confirm" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {} ({})" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_zone_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_zone_picking +#: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo +msgid "Zone Picking" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "" +"{picking.name} stock correction in location {location.name} for " +"{product_desc}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} is not a valid destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} not found in the current transfer or already in a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "%(qty)s %(product_name)s put in %(package_name)s" +msgstr "" + +#~ msgid "" +#~ "Created from backorder %s." +#~ msgstr "" +#~ "Creat des de comanda pendent %s." diff --git a/shopfloor/i18n/de.po b/shopfloor/i18n/de.po new file mode 100644 index 0000000000..48796b5a6c --- /dev/null +++ b/shopfloor/i18n/de.po @@ -0,0 +1,1791 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "" +"\n" +"If you tick this box, while picking goods from a location\n" +"(eg: zone picking) set destination will work as follow:\n" +"\n" +"* if a location is scanned, a new delivery package is created;\n" +"* if a package is scanned, the package is validated against the carrier\n" +"* in both cases, if the picking has no carrier the operation fails.\",\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__no_prefill_qty +msgid "" +"\n" +"We assume the picker will take the suggested quantities.\n" +"With this option, the operator will have to enter the quantity manually or\n" +"by scanning a product or product packaging EAN to increase the quantity\n" +"(i.e. +1 Unit or +1 Box)\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_return +msgid "" +"\n" +"When enabled, you can receive unplanned products that are returned\n" +"from an existing delivery matched on the origin (SO name).\n" +"A new move will be added as a return of the delivery,\n" +"decreasing the delivered quantity of the related SO line.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__multiple_move_single_pack +msgid "" +"\n" +"When picking a move,\n" +"allow to set a destination package that was already used for the other " +"lines.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__auto_post_line +msgid "" +"\n" +"When setting result pack & destination,\n" +"automatically post the corresponding line\n" +"if this option is checked.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__unload_package_at_destination +msgid "" +"\n" +"With this option, the lines you process by putting on a package during the\n" +"picking process will be put as bulk products at the final destination " +"location.\n" +"\n" +"This is useful if your picking device is emptied at the destination location " +"or\n" +"if you want to provide bulk products to the next operation.\n" +"\n" +"Incompatible with: \"Pick and pack at the same time\"\n" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"'Pick and pack at the same time' is incompatible with 'Multiple moves same " +"destination package'." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"'Pick and pack at the same time' is incompatible with 'Unload package at " +"destination'." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_app +msgid "A Shopfloor application" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "A destination package is required." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "A draft inventory has been created for control." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "Activate Zero Check" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin +msgid "Adds shopfloor priority/postpone fields" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "All packages processed." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_alternative_destination_is_possible +msgid "Allow Alternative Destination Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation_is_possible +msgid "Allow Force Reservation Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_get_work_is_possible +msgid "Allow Get Work Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create +msgid "Allow Move Creation" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_return_is_possible +msgid "Allow Return Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_return +msgid "Allow create returns" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "Allow to process reserved quantities" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_alternative_destination +msgid "Allow to scan alternative destination locations" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Are you sure?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__auto_post_line_is_possible +msgid "Auto Post Line Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__auto_post_line +msgid "Automatically post line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode does not match with {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode not found" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_batch +msgid "Batch Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer line done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Bin %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__bulk_line_count +msgid "Bulk Line Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Canceled, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Cannot change to lot {} which is entirely picked." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__category +msgid "Category" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_checkout +#: model:shopfloor.scenario,name:shopfloor.scenario_checkout +#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo +msgid "Checkout" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_cluster_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_cluster_picking +#: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo +msgid "Cluster Picking" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__multiple_move_single_pack +msgid "Collect multiple moves on a same destination package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Confirm location change from %s to %s?" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transfer to {} completed" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transferred from %(location_name)s to %(location_dest_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Control stock issue in location {} for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Create new PACK {}? Scan it again to confirm." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Creation of moves is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__date_planned +msgid "Date Scheduled" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_delivery +#: model:shopfloor.scenario,name:shopfloor.scenario_delivery +#: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo +msgid "Delivery" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Delivery package type scanned: %(name)s. Scan again to place all goods in " +"the same package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__disable_full_bin_action_is_possible +msgid "Disable Full Bin Action Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__disable_full_bin_action +msgid "Disable full bin action" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__show_oneline_package_content +msgid "Display the content of package if it contains 1 line only" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__no_prefill_qty +msgid "Do not pre-fill quantity to pick" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "" +"For Shopfloor scenarios using it (Cluster Picking, Zone Picking, Discrete " +"order Picking), the zero check step will be activated when a location " +"becomes empty after a move." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation +msgid "Force stock reservation" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id +msgid "From" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Goods packed into {0.name}" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__id +msgid "ID" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "" +"If you tick this box, the transfer is reserved only if the put-away can find " +"a sublocation (when putaway destination is different from the operation " +"type's destination)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "" +"If you tick this box, this scenario will allow operator to move goods even " +"if a reservation is made by a different operation type." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible +msgid "Ignore No Putaway Available Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "Ignore transfers when no put-away is available" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Ignoring not found putaway is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_inventory +msgid "Inventory" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_location +msgid "Inventory Locations" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_location____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/completion_info.py:0 +#, python-format +msgid "Last operation of transfer %(picking_names)s. Next operation (%(next_picking_names)s) is ready to " +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Line cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lines have different destination location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location %s doesn't contain any package." +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_location_content_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo +msgid "Location Content Transfer" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_location_content_transfer +msgid "Location content transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location empty. Try scanning a package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location not allowed here." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location {} empty" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Lot is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Lot {lot} for product {product} found in multiple locations. Scan your " +"location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Lot {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot {} is for another product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot %(old_lot_name)s replaced by lot %(new_lot_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Lot: " +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Missing expiration date." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible +msgid "Move Create Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids +msgid "Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count +msgid "Move Line Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Move already processed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Move lines processed have to share the same source location." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__multiple_move_single_pack_is_possible +msgid "Multiple Move Single Pack Is Possible" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Negative quantity not allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "New move lines cannot be assigned: canceled." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__no_prefill_qty_is_possible +msgid "No Prefill Qty Is Possible" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No available work could be found." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No delivery package type available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No line to pack found." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lines to process." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No location found for this barcode." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lot found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "No more work to do, please create a new batch transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation type found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/service.py:0 +#, python-format +msgid "No operation types configured on menu {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No package or lot found for barcode {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No pending operation for package %s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found in {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No putaway destination is available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No quantity has been processed, unable to complete the transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for the scanned lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for the scanned packaging." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for this lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for this product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No valid package to select." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No value" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Not all lines have been processed with full quantity. Do you confirm partial " +"operation?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids +msgid "Operation Types" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation already processed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Operation types for this menu are missing default source and destination " +"locations." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation's already running. Would you like to take it over?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__package_id +msgid "Package" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__package_level_count +msgid "Package Level Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package has been opened. You can move partial quantities." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Package level has to be in draft" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_quant_package.py:0 +#, python-format +msgid "Package name must be unique!" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Package {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s cannot be picked, already moved by transfer %(picking_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s cannot be used: %(error)s" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "" +"Package %(package_name)s does not contain available product %(product_name)s, cannot replace package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} has a different content." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Package {} has been partially picked in another location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is already used." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s is not available in transfer %(picking_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is not empty." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package {} is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(old_package_name)s replaced by package %(new_package_name)s." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant_package +msgid "Packages" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging '%(package_name)s' is not allowed for carrier %(carrier_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Packaging changed on package {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging not found in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Pick + Pack mode ON: the picking {0.name} has no carrier set. The system " +"couldn't pack goods automatically." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time_is_possible +msgid "Pick Pack Same Time Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "Pick and pack at the same time" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "Pick: stock issue on lot: %(lot_name)s found in %(location_name)s" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__picking_id +msgid "Picking" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count +msgid "Picking Count" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_type +msgid "Picking Type" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking has already been started in this location in transfer(s): {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking type {} complete." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Place it in {}?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Planned Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Please note that the scanned quantity is higher than the maximum allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Please scan the location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Please scan the package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__prepackaged_product_is_possible +msgid "Prepackaged Product Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_prepackaged_product +msgid "Process as pre-packaged" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Processing reserved quantities is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product tracked by lot, please scan one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Product {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product {} found in multiple locations. Scan your location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product(s) processed as raw product(s)" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant +msgid "Quants" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Real pack weight or the estimated one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Recovered previous session." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Remaining raw product not packed, proceed anyway?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids +msgid "Reserved Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Restart the operation, someone has canceled it." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF Priority" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF User" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF checkout done" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF unloaded" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Same package {} is already assigned." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scan_location_or_pack_first_is_possible +msgid "Scan Location Or Pack First Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scan_location_or_pack_first +msgid "Scan first location or pack" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Scan the destination location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Scan the package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"Scenario `%(scenario_name)s` require(s) 'Move Entire Packages' to be enabled.\n" +"These type(s) do not satisfy this constraint: \n" +"%(bad_picking_types)s.\n" +"Please, adjust your configuration." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__date_planned +msgid "Scheduled date until move is done, then date of actual move processing" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan a lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan the lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several moves found for different lots, please scan the lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Several moves found on different locations, please scan first the location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several operation types found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several packages found in %s, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan a product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan the product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "" +"Several transfers found, please scan a package or select a transfer manually." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several transfers found, please select a transfer manually." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form +#: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form +msgid "Shopfloor" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done +msgid "Shopfloor Checkout Done" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids +msgid "Shopfloor Menus" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "Shopfloor Picking Sequence" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "Shopfloor Postponed" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Shopfloor Priority" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded +msgid "Shopfloor Unloaded" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id +msgid "Shopfloor User" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.app,name:shopfloor.app_demo +msgid "Shopfloor WMS (demo)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Shopfloor weight (kg)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_get_work +msgid "Show Get Work on start" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__show_oneline_package_content_is_possible +msgid "Show Oneline Package Content Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__show_oneline_package_content +msgid "Show one-line package content" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_single_pack_transfer +msgid "Single Pack Transfer" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_single_pallet_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo +msgid "Single Pallet Transfer" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create +msgid "" +"Some scenario may create move(s) when a product or package is scanned and no " +"move already exists. Any new move is created in the selected operation type, " +"so it can be active only when one type is selected." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/stock.py:0 +#, python-format +msgid "Someone is already working on these transfers" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids +msgid "Source Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id +msgid "Source Package" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_package_level +msgid "Stock Package Level" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "" +"Technical field. Indicates if the operation has been postponed in a barcode " +"scenario." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count +msgid "Technical field. Indicates number of move lines included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__bulk_line_count +msgid "" +"Technical field. Indicates number of move lines without package included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__package_level_count +msgid "Technical field. Indicates number of package_level included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count +msgid "Technical field. Indicates number of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight +msgid "Technical field. Indicates total weight of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Technical field. Move lines for which destination is this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids +msgid "Technical field. Move lines moving this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Technical field. Overrides operation priority in barcode scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_picking.py:0 +#, python-format +msgid "" +"The backorder %s has been created." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The content of {} cannot be transferred with this scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "The destination bin {} is not empty, please take another." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The pack has been moved, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s cannot be transferred with this scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't contain any product to take." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The picked quantity must be a value above zero." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "" +"The picking done in Shopfloor scenarios will respect this order. The " +"sequence is a char so it can be composed of fields such as 'corridor-rack-" +"side-level'. Pay attention to the padding ('09' is before '19', '9' is not). " +"It is recommended to use an Export then an Import to populate this field " +"using a spreadsheet." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The product/packaging you selected has already been returned." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"The quantity scanned for one or more lines cannot be higher than the maximum " +"allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The record you were working on does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move.py:0 +#, python-format +msgid "The split order {} has been created." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id +msgid "The stock operation where the packing has been made" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "This batch cannot be selected." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line has a package, please select the package instead." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line is not available in transfer {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "This location content can't be moved at once." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location does not exist." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location only contains packages, please scan one of them." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location requires packages. Please scan a destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This lot is part of a package with other products, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This lot is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This operation does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This package does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This product is part of a package with other products, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of a package, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This source document is part of multiple transfers, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This transfer does not exist or is not available anymore." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight +msgid "Total Weight" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id +msgid "Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} is not available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Units replaced by package {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unload_package_at_destination_is_possible +msgid "Unload Package At Destination Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unload_package_at_destination +msgid "Unload package at destination" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Unrecoverable error, please restart." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible +msgid "Unreserve Other Moves Is Possible" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.profile,name:shopfloor.profile_demo_2 +msgid "WH delivery" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.profile,name:shopfloor.profile_demo_1 +msgid "WH worker" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_app__category__wms +msgid "WMS" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.app,short_name:shopfloor.app_demo +msgid "WMS (demo)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_prepackaged_product +msgid "" +"When active, what you scan (typically a product packaging EAN) will be ship " +"'as-is' and the operation will be validated triggering a backorder creation " +"with the remaining lines." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_get_work +msgid "" +"When enabled the user will have the option to ask for a task to work on." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_alternative_destination +msgid "" +"When enabled the user will have the option to scan destination locations " +"other than the expected ones (ask for confirmation)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__disable_full_bin_action +msgid "When picking, prevent unloading the whole bin when full." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__scan_location_or_pack_first +msgid "" +"When selecting work, force the user to first scan a location or pack,then " +"the product or lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Working location changed to {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "Wrong bin" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong packaging." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot move this using this menu." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot place it here" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot return more quantity than what was initially sent." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot work on a package (%s) outside of locations: %s" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You must not pick more than {} units." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"You scanned a different package with the same product, do you want to change " +"pack? Scan it again to confirm" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {} ({})" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_zone_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_zone_picking +#: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo +msgid "Zone Picking" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "" +"{picking.name} stock correction in location {location.name} for " +"{product_desc}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} is not a valid destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} not found in the current transfer or already in a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "%(qty)s %(product_name)s put in %(package_name)s" +msgstr "" diff --git a/shopfloor/i18n/es_AR.po b/shopfloor/i18n/es_AR.po new file mode 100644 index 0000000000..2760c31591 --- /dev/null +++ b/shopfloor/i18n/es_AR.po @@ -0,0 +1,2147 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-03-12 20:14+0000\n" +"Last-Translator: Ignacio Buioli \n" +"Language-Team: none\n" +"Language: es_AR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.14.1\n" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "" +"\n" +"If you tick this box, while picking goods from a location\n" +"(eg: zone picking) set destination will work as follow:\n" +"\n" +"* if a location is scanned, a new delivery package is created;\n" +"* if a package is scanned, the package is validated against the carrier\n" +"* in both cases, if the picking has no carrier the operation fails.\",\n" +msgstr "" +"\n" +"Si marca esta casilla, mientras recoge mercancías de una ubicación\n" +"(por ejemplo: selección de zona) el destino establecido funcionará de la " +"siguiente manera:\n" +"\n" +"* si se escanea una ubicación, se crea un nuevo paquete de entrega;\n" +"* si se escanea un paquete, el paquete se valida con el transportista\n" +"* en ambos casos, si el picking no tiene transportista la operación falla. " +"\",\n" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__no_prefill_qty +msgid "" +"\n" +"We assume the picker will take the suggested quantities.\n" +"With this option, the operator will have to enter the quantity manually or\n" +"by scanning a product or product packaging EAN to increase the quantity\n" +"(i.e. +1 Unit or +1 Box)\n" +msgstr "" +"\n" +"Asumimos que el recolector tomará las cantidades sugeridas.\n" +"Con esta opción, el operador deberá ingresar la cantidad manualmente o\n" +"escaneando un producto o embalaje de producto EAN para aumentar la cantidad\n" +"(es decir, +1 Unidad o +1 Caja)\n" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_return +msgid "" +"\n" +"When enabled, you can receive unplanned products that are returned\n" +"from an existing delivery matched on the origin (SO name).\n" +"A new move will be added as a return of the delivery,\n" +"decreasing the delivered quantity of the related SO line.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__multiple_move_single_pack +msgid "" +"\n" +"When picking a move,\n" +"allow to set a destination package that was already used for the other " +"lines.\n" +msgstr "" +"\n" +"Al elegir un movimiento,\n" +"permite establecer un paquete de destino que ya se utilizó para las otras " +"líneas.\n" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__auto_post_line +msgid "" +"\n" +"When setting result pack & destination,\n" +"automatically post the corresponding line\n" +"if this option is checked.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__unload_package_at_destination +msgid "" +"\n" +"With this option, the lines you process by putting on a package during the\n" +"picking process will be put as bulk products at the final destination " +"location.\n" +"\n" +"This is useful if your picking device is emptied at the destination location " +"or\n" +"if you want to provide bulk products to the next operation.\n" +"\n" +"Incompatible with: \"Pick and pack at the same time\"\n" +msgstr "" +"\n" +"Con esta opción, las líneas que procese poniendo un paquete durante el\n" +"proceso de selección se pondrán como productos a granel en el lugar de " +"destino final.\n" +"\n" +"Esto es útil si su dispositivo de recolección se vacía en la ubicación de " +"destino o\n" +"si desea proporcionar productos a granel a la siguiente operación.\n" +"\n" +"Incompatible con: \"Recoger y empaquetar al mismo tiempo\"\n" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"'Pick and pack at the same time' is incompatible with 'Multiple moves same " +"destination package'." +msgstr "" +"'Recoger y empaquetar al mismo tiempo' es incompatible con 'Múltiples " +"movimientos en el mismo paquete de destino'." + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"'Pick and pack at the same time' is incompatible with 'Unload package at " +"destination'." +msgstr "" +"'Recoger y empaquetar al mismo tiempo' es incompatible con 'Descargar " +"paquete en destino'." + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_app +msgid "A Shopfloor application" +msgstr "Una Aplicación de Taller" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "A destination package is required." +msgstr "Un paquete de destino es requerido." + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "A draft inventory has been created for control." +msgstr "Se ha creado un borrador de inventario para su control." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "Activate Zero Check" +msgstr "Activar Verificación Cero" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin +msgid "Adds shopfloor priority/postpone fields" +msgstr "Agrega campos de prioridad / aplazamiento del taller" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "All packages processed." +msgstr "Todos los paquetes procesados." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_alternative_destination_is_possible +msgid "Allow Alternative Destination Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation_is_possible +msgid "Allow Force Reservation Is Possible" +msgstr "Permitir Forzar Reserva es Posible" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_get_work_is_possible +msgid "Allow Get Work Is Possible" +msgstr "Permitir Conseguir Trabajo Es Posible" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create +msgid "Allow Move Creation" +msgstr "Permitir Creación de Movimiento" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_return_is_possible +msgid "Allow Return Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_return +msgid "Allow create returns" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "Allow to process reserved quantities" +msgstr "Permitir procesar cantidades reservadas" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_alternative_destination +msgid "Allow to scan alternative destination locations" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Are you sure?" +msgstr "¿Está seguro?" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__auto_post_line_is_possible +msgid "Auto Post Line Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__auto_post_line +msgid "Automatically post line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode does not match with {}." +msgstr "Código de barras no coincide con {}." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode not found" +msgstr "Código de barras no encontrado" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_batch +msgid "Batch Transfer" +msgstr "Transferencia por Lotes" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer complete" +msgstr "Transferencia por Lotes completa" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer line done" +msgstr "Línea de Transferencia por lotes hecha" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Bin %s doesn't exist" +msgstr "Compartimento %s no existe" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__bulk_line_count +msgid "Bulk Line Count" +msgstr "Cuenta de Líneas de Bultos" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Canceled, you can scan a new pack." +msgstr "Cancelado, puede escanear un nuevo paquete." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Cannot change to lot {} which is entirely picked." +msgstr "No se puede cambiar al lote {} ya que está completamente recogido." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__category +msgid "Category" +msgstr "Categoría" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_checkout +#: model:shopfloor.scenario,name:shopfloor.scenario_checkout +#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo +msgid "Checkout" +msgstr "Checkout" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_cluster_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_cluster_picking +#: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo +msgid "Cluster Picking" +msgstr "Grupo de Picking" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__multiple_move_single_pack +msgid "Collect multiple moves on a same destination package" +msgstr "Recolecta múltiples movimientos en un mismo paquete de destino" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Confirm location change from %s to %s?" +msgstr "¿Confirma cambiar ubicación desde %s hacia %s?" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transfer to {} completed" +msgstr "Transferencia de contenido a {} completada" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transferred from %(location_name)s to %(location_dest_name)s." +msgstr "Transferencia de contenido desde %(location_name)s hacia %(location_dest_name)s." + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Control stock issue in location {} for {}" +msgstr "Error en control de inventario en ubicación {} para {}" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Create new PACK {}? Scan it again to confirm." +msgstr "Crear un nuevo PAQUETE {}? Escanee nuevamente para confirmar." + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Creation of moves is not allowed for menu {}." +msgstr "La creación de movimientos no está permitida para el menú {}." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__date_planned +msgid "Date Scheduled" +msgstr "Fecha Planificada" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_delivery +#: model:shopfloor.scenario,name:shopfloor.scenario_delivery +#: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo +msgid "Delivery" +msgstr "Entrega" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Delivery package type scanned: %(name)s. Scan again to place all goods in " +"the same package." +msgstr "" +"Tipo de paquete de entrega escaneado: %(name)s. Escanee nuevamente para " +"colocar todos los productos en el mismo paquete." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__disable_full_bin_action_is_possible +msgid "Disable Full Bin Action Is Possible" +msgstr "Es Posible Deshabilitar la Acción de Contenedor Lleno" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__disable_full_bin_action +msgid "Disable full bin action" +msgstr "Deshabilitar Acción de Contenedor Lleno" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__display_name +msgid "Display Name" +msgstr "Mostrar Nombre" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__show_oneline_package_content +msgid "Display the content of package if it contains 1 line only" +msgstr "Mostrar el contenido del paquete si contiene solo 1 línea" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__no_prefill_qty +msgid "Do not pre-fill quantity to pick" +msgstr "No pre-rellenar la cantidad a recoger" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "" +"For Shopfloor scenarios using it (Cluster Picking, Zone Picking, Discrete " +"order Picking), the zero check step will be activated when a location " +"becomes empty after a move." +msgstr "" +"Para los escenarios del Taller que lo utilizan (Selección de grupos, " +"Selección de zonas, Selección de pedidos discretos), el paso de verificación " +"cero se activará cuando una ubicación quede vacía después de un movimiento." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation +msgid "Force stock reservation" +msgstr "Forzar reserva de inventario" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id +msgid "From" +msgstr "Desde" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Goods packed into {0.name}" +msgstr "Mercadería empaquetada en {0.name}" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__id +msgid "ID" +msgstr "ID" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "" +"If you tick this box, the transfer is reserved only if the put-away can find " +"a sublocation (when putaway destination is different from the operation " +"type's destination)." +msgstr "" +"Si marca esta casilla, la transferencia se reserva solo si la ubicación " +"puede encontrar una sububicación (cuando el destino de la ubicación es " +"diferente del destino del tipo de operación)." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "" +"If you tick this box, this scenario will allow operator to move goods even " +"if a reservation is made by a different operation type." +msgstr "" +"Si marca esta casilla, este escenario permitirá al operador mover mercancías " +"incluso si se realiza una reserva mediante un tipo de operación diferente." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible +msgid "Ignore No Putaway Available Is Possible" +msgstr "Ignorar que No Hay Almacenamiento Disponible Es Posible" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "Ignore transfers when no put-away is available" +msgstr "Ignora las transferencias cuando no haya disponibilidad de ubicación" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Ignoring not found putaway is not allowed for menu {}." +msgstr "No se permite ignorar el almacenamiento no encontrado para el menú {}." + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_inventory +msgid "Inventory" +msgstr "Inventario" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_location +msgid "Inventory Locations" +msgstr "Ubicaciones de Inventario" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_location____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package____last_update +msgid "Last Modified on" +msgstr "Última Modificación el" + +#. module: shopfloor +#: code:addons/shopfloor/actions/completion_info.py:0 +#, python-format +msgid "Last operation of transfer %(picking_names)s. Next operation (%(next_picking_names)s) is ready to " +msgstr "" +"Última operación de transferencia: %(picking_names)s. Siguiente operación (%(next_picking_names)s) está lista " +"para proceder." + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Line cancelled" +msgstr "Línea cancelada" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lines have different destination location." +msgstr "La líneas tiene diferente ubicación de destino." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location %s doesn't contain any package." +msgstr "La Ubicación %s no contiene ningún paquete." + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_location_content_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo +msgid "Location Content Transfer" +msgstr "Transferencia de Contenido de Ubicación" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_location_content_transfer +msgid "Location content transfer" +msgstr "Transferencia de contenido de ubicación" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location empty. Try scanning a package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location not allowed here." +msgstr "La Ubicación no está permitida aquí." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location {} empty" +msgstr "Ubicación {} vacía" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Lot is not in the current transfer." +msgstr "El Lote no está en la transferencia actual." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Lot {lot} for product {product} found in multiple locations. Scan your " +"location first." +msgstr "" +"El Lote {lot} para el producto {product} no se encontrado en múltiples " +"ubicaciones. Escanée su ubicación primero." + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Lot {} belongs to a picking without a valid state." +msgstr "El Lote {} pertenece a un picking sin estado válido." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot {} is for another product." +msgstr "El Lote {} es para otro producto." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot %(old_lot_name)s replaced by lot %(new_lot_name)s." +msgstr "Lote %(old_lot_name)s reemplazado por lote %(new_lot_name)s." + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Lot: " +msgstr "Lote: " + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "Menú mostrado en la aplicación de escaner" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Missing expiration date." +msgstr "Falta la fecha de vencimiento." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible +msgid "Move Create Is Possible" +msgstr "Crear Movimiento es Posible" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids +msgid "Move Line" +msgstr "Línea de Movimiento" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count +msgid "Move Line Count" +msgstr "Cuenta de Línea de Movimiento" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Move already processed." +msgstr "El Movimiento ya ha sido procesado." + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Move lines processed have to share the same source location." +msgstr "" +"Movimiento de líneas procesadas tienen que compartir la misma ubicación." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__multiple_move_single_pack_is_possible +msgid "Multiple Move Single Pack Is Possible" +msgstr "Paquete Individual de Movimiento Múltiple Es Posible" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Negative quantity not allowed." +msgstr "Cantidad negativa no permitida." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "New move lines cannot be assigned: canceled." +msgstr "Los nuevos movimientos de líneas no puede ser asignados: cancelados." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__no_prefill_qty_is_possible +msgid "No Prefill Qty Is Possible" +msgstr "No Prerellenar la Cantidad Es Posible" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No available work could be found." +msgstr "Ningún trabajo disponible ha sido encontrado." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No delivery package type available." +msgstr "No hay un tipo de paquete de entrega disponible." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No line to pack found." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lines to process." +msgstr "No hay líneas para procesar." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No location found for this barcode." +msgstr "No se encontró ubicación para este código de barras." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lot found for {}" +msgstr "No se encontró lote para {}" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "No more work to do, please create a new batch transfer" +msgstr "No más trabajo por hacer, cree una nueva transferencia por lotes" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation type found for this menu and profile." +msgstr "No se ha encontrado ningún tipo de operación para este menú y perfil." + +#. module: shopfloor +#: code:addons/shopfloor/services/service.py:0 +#, python-format +msgid "No operation types configured on menu {}." +msgstr "No hay tipos de operación configurados en el menú {}." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No package or lot found for barcode {}." +msgstr "No hay paquete o lote encontrado para el código de barras {}." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No pending operation for package %s." +msgstr "No hay operación pendiente para el paquete %s." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found for {}" +msgstr "No se encontró producto para {}" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found in {}" +msgstr "No se encontró producto en {}" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No putaway destination is available." +msgstr "No hay ningún destino de almacenamiento disponible." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No quantity has been processed, unable to complete the transfer." +msgstr "" +"No se ha procesado ninguna cantidad, no se ha podido completar la " +"transferencia." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for the scanned lot." +msgstr "No se encontraron transferencias para el lote escaneado." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for the scanned packaging." +msgstr "No se ha encontrado ninguna transferencia para el paquete escaneado." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for this lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for this product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No valid package to select." +msgstr "No hay paquete válido para seleccionar." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No value" +msgstr "Sin valor" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Not all lines have been processed with full quantity. Do you confirm partial " +"operation?" +msgstr "" +"No todas las líneas se han procesado con la cantidad completa. ¿Confirma " +"operación parcial?" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids +msgid "Operation Types" +msgstr "Tipos de Operación" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation already processed." +msgstr "Operación ya procesada." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Operation types for this menu are missing default source and destination " +"locations." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation's already running. Would you like to take it over?" +msgstr "La operación ya está en marcha. ¿Le gustaría hacerse cargo?" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__package_id +msgid "Package" +msgstr "Paquete" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__package_level_count +msgid "Package Level Count" +msgstr "Cuental del Nivel de Paquete" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package cancelled" +msgstr "Paquete cancelado" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package has been opened. You can move partial quantities." +msgstr "El paquete ha sido abierto. Puede mover cantidades parciales." + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Package level has to be in draft" +msgstr "El paquete tiene que estar en Borrador" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_quant_package.py:0 +#, python-format +msgid "Package name must be unique!" +msgstr "¡El nombre del Paquete debe ser único!" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Package {} belongs to a picking without a valid state." +msgstr "El Paquete {} pertenece a una entrega sin un estado válido." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s cannot be picked, already moved by transfer %(picking_name)s." +msgstr "" +"El Paquete %(package_name)s no puede ser seleccionado, ya ha sido movido por la " +"transferencia %(picking_name)s." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s cannot be used: %(error)s" +msgstr "El Paquete %(package_name)s no puede ser usado: %(error)s" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "" +"Package %(package_name)s does not contain available product %(product_name)s, cannot replace package." +msgstr "" +"El Paquete %(package_name)s no contiene un producto disponible %(product_name)s, no se puede reemplazar " +"el paquete." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} has a different content." +msgstr "El Paquete {} tiene diferente contenido." + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Package {} has been partially picked in another location" +msgstr "El Paquete {} ha sido parcialmente entregado en otra ubicación" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is already used." +msgstr "El Paquete {} está usado." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s is not available in transfer %(picking_name)s." +msgstr "El Paquete %(package_name)s no está disponible en la transferencia %(picking_name)s." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is not empty." +msgstr "El Paquete {} no está vacío." + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package {} is not in the current transfer." +msgstr "El Paquete {} no está en la transferencia actual." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(old_package_name)s replaced by package %(new_package_name)s." +msgstr "El Paquete %(old_package_name)s está reemplazado por el paquete %(new_package_name)s." + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant_package +msgid "Packages" +msgstr "Paquetes" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging '%(package_name)s' is not allowed for carrier %(carrier_name)s." +msgstr "El Paquete '%(package_name)s' no está permitido para el transportista %(carrier_name)s." + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Packaging changed on package {}" +msgstr "El Empaquetado cambió en el paquete {}" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging not found in the current transfer." +msgstr "Paquete no encontrado en la transferencia actual." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Pick + Pack mode ON: the picking {0.name} has no carrier set. The system " +"couldn't pack goods automatically." +msgstr "" +"Modo Pick + Pack ACTIVADO: el picking {0.name} no tiene un transportista " +"configurado. El sistema no podrá empaquetar mercadería automáticamente." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time_is_possible +msgid "Pick Pack Same Time Is Possible" +msgstr "Pick / Pack al mismo tiempo es Posible" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "Pick and pack at the same time" +msgstr "Pick y pack al mismo tiempo" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "Pick: stock issue on lot: %(lot_name)s found in %(location_name)s" +msgstr "Entrega: error de inventario en el lote: %(lot_name)s encontrado en %(location_name)s" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__picking_id +msgid "Picking" +msgstr "Entrega" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count +msgid "Picking Count" +msgstr "Cuenta de Entrega" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_type +msgid "Picking Type" +msgstr "Tipo de Entrega" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking has already been started in this location in transfer(s): {}" +msgstr "" +"La Entrega ya ha sido iniciada en esta ubicación en la(s) transferencia(s): " +"{}" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking type {} complete." +msgstr "Tipo de Entrega {} completo." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Place it in {}?" +msgstr "¿Colocarlo en {}?" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Planned Move Line" +msgstr "Línea de Movimiento Planificada" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Please note that the scanned quantity is higher than the maximum allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Please scan the location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Please scan the package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__prepackaged_product_is_possible +msgid "Prepackaged Product Is Possible" +msgstr "Producto PreEmpaquetado Es Posible" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_prepackaged_product +msgid "Process as pre-packaged" +msgstr "Procesar como pre-empaquetado" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Processing reserved quantities is not allowed for menu {}." +msgstr "Procesar cantidades reservadas no está permitido para el menú {}." + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "Movimientos de Producto (Stock Move Line)" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product is not in the current transfer." +msgstr "El Producto no está en la transferencia actual." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product tracked by lot, please scan one." +msgstr "Producto rastreado por lote, por favor escanée uno." + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Product {} belongs to a picking without a valid state." +msgstr "Producto {} pertenece a una entrega sin estado válido." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product {} found in multiple locations. Scan your location first." +msgstr "" +"El Producto {} se ha encontrado en múltiples ubicaciones. Escanée su " +"ubicación primero." + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product(s) processed as raw product(s)" +msgstr "Producto(s) procesado(s) como producto(s) crudo(s)" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant +msgid "Quants" +msgstr "Cantidades" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Real pack weight or the estimated one." +msgstr "Peso real del paquete o estimado." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Recovered previous session." +msgstr "Sesión anterior recuperada." + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Remaining raw product not packed, proceed anyway?" +msgstr "" +"El producto crudo restante no está empaquetado, ¿continuar de todos modos?" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids +msgid "Reserved Move Line" +msgstr "Movimiento de Línea Reservado" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Restart the operation, someone has canceled it." +msgstr "Reinicie la operación, alguien la ha cancelado." + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF Priority" +msgstr "Prioridad del Taller" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF User" +msgstr "Usuario SF" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF checkout done" +msgstr "Checkout del Taller Hecho" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF unloaded" +msgstr "Taller Descargado" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Same package {} is already assigned." +msgstr "El mismo paquete {} ya está asignado." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scan_location_or_pack_first_is_possible +msgid "Scan Location Or Pack First Is Possible" +msgstr "Escanear Ubicación O Paquete Primero es Posible" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scan_location_or_pack_first +msgid "Scan first location or pack" +msgstr "Escanear ubicación o paquete primero" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Scan the destination location" +msgstr "Escanear la ubicación de destino" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Scan the package" +msgstr "Escanear el paquete" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"Scenario `%(scenario_name)s` require(s) 'Move Entire Packages' to be enabled.\n" +"These type(s) do not satisfy this constraint: \n" +"%(bad_picking_types)s.\n" +"Please, adjust your configuration." +msgstr "" +"El escenario `%(scenario_name)s` requiere que se habilite 'Mover paquetes completos'.\n" +"Estos tipos no satisfacen esta restricción:\n" +"%(bad_picking_types)s.\n" +"Por favor, ajuste su configuración." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__date_planned +msgid "Scheduled date until move is done, then date of actual move processing" +msgstr "" +"Fecha planificada hasta que se realiza el movimiento, luego fecha de " +"procesamiento del movimiento actual" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan a lot." +msgstr "Se han encontrado varios lotes en %s, escanee mucho." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan the lot." +msgstr "Demasiados lotes encontrados en %s, escanée el lote." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several moves found for different lots, please scan the lot." +msgstr "" +"Demasiados movimientos encontrados para diferentes lotes, escanée el lote." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Several moves found on different locations, please scan first the location." +msgstr "" +"Demasiados movimientos encontrados en difrentes ubicaciones, escanée la " +"primer ubicación." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several operation types found for this menu and profile." +msgstr "Se han encontrado varios tipos de operaciones para este menú y perfil." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several packages found in %s, please scan a package." +msgstr "Se han encontrado varios paquetes en %s, escanee un paquete." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan a product." +msgstr "Se han encontrado varios productos en %s, escanee un producto." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan the product." +msgstr "Demasiados productos encontrados en %s, escanée el producto." + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "" +"Several transfers found, please scan a package or select a transfer manually." +msgstr "" +"Se han encontrado varias transferencias, escanee un paquete o seleccione una " +"transferencia manualmente." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several transfers found, please select a transfer manually." +msgstr "" +"Demasiadas transferencias encontradas, seleccione una transferencia " +"manualmente." + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form +#: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form +msgid "Shopfloor" +msgstr "Taller" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done +msgid "Shopfloor Checkout Done" +msgstr "Checkout del Taller Hecho" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids +msgid "Shopfloor Menus" +msgstr "Menús del Taller" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "Shopfloor Picking Sequence" +msgstr "Secuencia del Picking del Taller" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "Shopfloor Postponed" +msgstr "Taller Pospuesto" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Shopfloor Priority" +msgstr "Prioridad del Taller" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded +msgid "Shopfloor Unloaded" +msgstr "Taller Descargado" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id +msgid "Shopfloor User" +msgstr "Usuario del Taller" + +#. module: shopfloor +#: model:shopfloor.app,name:shopfloor.app_demo +msgid "Shopfloor WMS (demo)" +msgstr "Taller WMS (demo)" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Shopfloor weight (kg)" +msgstr "Peso del taller (kg)" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_get_work +msgid "Show Get Work on start" +msgstr "Mostrar Obtener Trabajo en el inicio" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__show_oneline_package_content_is_possible +msgid "Show Oneline Package Content Is Possible" +msgstr "Mostrar Paquetes de Una Línea Es Posible" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__show_oneline_package_content +msgid "Show one-line package content" +msgstr "Mostrar contenido de paquete de una-línea" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_single_pack_transfer +msgid "Single Pack Transfer" +msgstr "Transferencia de Paquete Individual" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_single_pallet_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo +msgid "Single Pallet Transfer" +msgstr "Transferencia de un solo Palet" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create +msgid "" +"Some scenario may create move(s) when a product or package is scanned and no " +"move already exists. Any new move is created in the selected operation type, " +"so it can be active only when one type is selected." +msgstr "" +"Algunos escenarios pueden crear movimientos cuando se escanea un producto o " +"paquete cuando no existe ningún movimiento. Cualquier movimiento nuevo se " +"crea en el tipo de operación seleccionado, por lo que solo puede estar " +"activo cuando se selecciona un tipo." + +#. module: shopfloor +#: code:addons/shopfloor/actions/stock.py:0 +#, python-format +msgid "Someone is already working on these transfers" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids +msgid "Source Move Line" +msgstr "Recurso del Movimiento de Línea" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id +msgid "Source Package" +msgstr "Recurso del paquete" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move +msgid "Stock Move" +msgstr "Movimiento de Inventario" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_package_level +msgid "Stock Package Level" +msgstr "Nivel de Paquete de Existencias" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "" +"Technical field. Indicates if the operation has been postponed in a barcode " +"scenario." +msgstr "" +"Campo técnico. Indica si la operación se ha pospuesto en un escenario de " +"código de barras." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count +msgid "Technical field. Indicates number of move lines included." +msgstr "Campo técnico. Indica el número de líneas de movimiento incluidas." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__bulk_line_count +msgid "" +"Technical field. Indicates number of move lines without package included." +msgstr "" +"Campo técnico. Indica el número de lineas de movimiento sin paquete incluído." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__package_level_count +msgid "Technical field. Indicates number of package_level included." +msgstr "Campo técnico. Indica el número del package_level incluído." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count +msgid "Technical field. Indicates number of transfers included." +msgstr "Campo técnico. Indica el número de transferencias incluidas." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight +msgid "Technical field. Indicates total weight of transfers included." +msgstr "Campo técnico. Indica el peso total de las transferencias incluidas." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Technical field. Move lines for which destination is this package." +msgstr "Campo técnico. Mueva las líneas para qué destino es este paquete." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids +msgid "Technical field. Move lines moving this package." +msgstr "Campo técnico. Mueva líneas moviendo este paquete." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Technical field. Overrides operation priority in barcode scenario." +msgstr "" +"Campo técnico. Anula la prioridad de operación en el escenario del código de " +"barras." + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_picking.py:0 +#, python-format +msgid "" +"The backorder %s has been created." +msgstr "" +"Se ha creado el pedido pendiente %s." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The content of {} cannot be transferred with this scenario." +msgstr "El contenido de {} no se puede transferir con este escenario." + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "The destination bin {} is not empty, please take another." +msgstr "El contenedor de destino {} no está vacío, tome otro." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The pack has been moved, you can scan a new pack." +msgstr "El paquete se ha movido, puede escanear un paquete nuevo." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s cannot be transferred with this scenario." +msgstr "El paquete %s no puede ser transferido con este escenario." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't contain any product to take." +msgstr "El paquete %s no contiene un producto para empaquetar." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't exist" +msgstr "El paquete %s no existe" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The picked quantity must be a value above zero." +msgstr "La cantidad seleccionada debe ser un valor por encima de cero." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "" +"The picking done in Shopfloor scenarios will respect this order. The " +"sequence is a char so it can be composed of fields such as 'corridor-rack-" +"side-level'. Pay attention to the padding ('09' is before '19', '9' is not). " +"It is recommended to use an Export then an Import to populate this field " +"using a spreadsheet." +msgstr "" +"La entrega realizada en los escenarios del Taller respetará este orden. La " +"secuencia es un char, por lo que puede estar compuesta por campos como " +"'corredor-rack-side-level'. Preste atención al relleno ('09' es antes de " +"'19', '9' no). Se recomienda usar Exportar y luego Importar para completar " +"este campo usando una hoja de cálculo." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The product/packaging you selected has already been returned." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"The quantity scanned for one or more lines cannot be higher than the maximum " +"allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The record you were working on does not exist anymore." +msgstr "El registro en el que estaba trabajando ya no existe." + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move.py:0 +#, python-format +msgid "The split order {} has been created." +msgstr "Se ha creado el pedido dividido {}." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id +msgid "The stock operation where the packing has been made" +msgstr "La operación de inventario donde se ha realizado el embalaje" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "This batch cannot be selected." +msgstr "Este lote no se puede seleccionar." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line has a package, please select the package instead." +msgstr "Esta línea tiene un paquete, seleccione el paquete en su lugar." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line is not available in transfer {}." +msgstr "Esta línea no está disponible en la transferencia {}." + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "This location content can't be moved at once." +msgstr "El contenido de esta ubicación no se puede mover a la vez." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location does not exist." +msgstr "Esta ubicación no existe." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location only contains packages, please scan one of them." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location requires packages. Please scan a destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This lot is part of a package with other products, please scan a package." +msgstr "" +"Este lote es parte de un paquete con otros productos, escanee un paquete." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This lot is part of multiple packages, please scan a package." +msgstr "Este lote es parte de varios paquetes, escanee un paquete." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This operation does not exist anymore." +msgstr "Esta operación ya no existe." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This package does not exist anymore." +msgstr "Este paquete ya no existe." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product does not exist anymore." +msgstr "Este producto ya no existe." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This product is part of a package with other products, please scan a package." +msgstr "" +"Este producto es parte de un paquete con otros productos, escanee un paquete." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of a package, please scan a package." +msgstr "Este producto es parte de un paquete, escanée un paquete." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of multiple packages, please scan a package." +msgstr "Este producto es parte de varios paquetes, escanee un paquete." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This source document is part of multiple transfers, please scan a package." +msgstr "" +"Este documento de origen es parte de múltiples transferencias, escanee un " +"paquete." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This transfer does not exist or is not available anymore." +msgstr "Esta transferencia no existe o ya no está disponible." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight +msgid "Total Weight" +msgstr "Peso Total" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id +msgid "Transfer" +msgstr "Transferencia" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} complete" +msgstr "Transferencia {} completa" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} done" +msgstr "Transferencia {} realizada" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} is not available." +msgstr "Transferencia {} no está disponible." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Units replaced by package {}." +msgstr "Unidades reemplazadas por paquete {}." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unload_package_at_destination_is_possible +msgid "Unload Package At Destination Is Possible" +msgstr "Descargar Paquete En El Destino Es Posible" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unload_package_at_destination +msgid "Unload package at destination" +msgstr "Descargar paquete en destino" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Unrecoverable error, please restart." +msgstr "Error irrecuperable, por favor reinicie." + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible +msgid "Unreserve Other Moves Is Possible" +msgstr "Es posible Anular la Reserva de Otros Movimientos" + +#. module: shopfloor +#: model:shopfloor.profile,name:shopfloor.profile_demo_2 +msgid "WH delivery" +msgstr "Entrega WH" + +#. module: shopfloor +#: model:shopfloor.profile,name:shopfloor.profile_demo_1 +msgid "WH worker" +msgstr "Trabajador WH" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_app__category__wms +msgid "WMS" +msgstr "WMS" + +#. module: shopfloor +#: model:shopfloor.app,short_name:shopfloor.app_demo +msgid "WMS (demo)" +msgstr "WMS (demo)" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_prepackaged_product +msgid "" +"When active, what you scan (typically a product packaging EAN) will be ship " +"'as-is' and the operation will be validated triggering a backorder creation " +"with the remaining lines." +msgstr "" +"Cuando está activo, lo que escanea (generalmente un EAN de empaque de " +"producto) se enviará 'tal cual' y la operación se validará activando la " +"creación de un pedido pendiente con las líneas restantes." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_get_work +msgid "" +"When enabled the user will have the option to ask for a task to work on." +msgstr "" +"Cuando está habilitado el usuario tendrá la opción de solicitar una tarea " +"para trabajar." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_alternative_destination +msgid "" +"When enabled the user will have the option to scan destination locations " +"other than the expected ones (ask for confirmation)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__disable_full_bin_action +msgid "When picking, prevent unloading the whole bin when full." +msgstr "" +"Al hacer el picking, evite descargar todo el contenedor cuando esté lleno." + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__scan_location_or_pack_first +msgid "" +"When selecting work, force the user to first scan a location or pack,then " +"the product or lot." +msgstr "" +"Cuando seleccione un trabajo, fuerza al usuario a escanear primero una " +"ubicación o paquete, después el producto o lote." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Working location changed to {}" +msgstr "Ubicación de trabajo cambiada a {}" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "Wrong bin" +msgstr "Compartimento incorrecto" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong location." +msgstr "Ubicación equivocada." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong lot." +msgstr "Lote equivocado." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong pack." +msgstr "Paquete equivocado." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong packaging." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong product." +msgstr "Producto equivocado." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong." +msgstr "Incorrecto." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot move this using this menu." +msgstr "No puede mover esto usando este menú." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot place it here" +msgstr "No puede colocarlo aquí" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot return more quantity than what was initially sent." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot work on a package (%s) outside of locations: %s" +msgstr "No puede trabajar en el paquete (%s) fuera de la ubicación: %s" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You must not pick more than {} units." +msgstr "No debe seleccionar más de {} unidades." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"You scanned a different package with the same product, do you want to change " +"pack? Scan it again to confirm" +msgstr "" +"Escaneó un paquete diferente con el mismo producto, ¿desea cambiar el " +"paquete? Escanéalo de nuevo para confirmar" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {}" +msgstr "Error de verificación cero en la ubicación {}" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {} ({})" +msgstr "Error de verificación cero en la ubicación {} ({})" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_zone_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_zone_picking +#: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo +msgid "Zone Picking" +msgstr "Zona de Entreda" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "" +"{picking.name} stock correction in location {location.name} for " +"{product_desc}" +msgstr "" +"{picking.name} corrección de inventario en la ubicación {location.name} para " +"{product_desc}" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} is not a valid destination package." +msgstr "{} no es un paquete de destino válido." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} not found in the current transfer or already in a package." +msgstr "{} no encontrado en la transferencia actual o ya está en un paquete." + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "%(qty)s %(product_name)s put in %(package_name)s" +msgstr "%(qty)s %(product_name)s poner en %(package_name)s" + +#, python-format +#~ msgid "No line to pack found" +#~ msgstr "No se encontró ninguna línea para empaquetar" + +#, python-format +#~ msgid "" +#~ "'Multiple moves same destination package' is mandatory when 'Pick and " +#~ "pack at the same time' is set." +#~ msgstr "" +#~ "'Múltiples movimientos en el mismo paquete de destino' es obligatorio " +#~ "cuando 'Recoger y empaquetar al mismo tiempo' está configurado." + +#, python-format +#~ msgid "" +#~ "Not allowed to pack more than the quantity, the value has been changed to " +#~ "the maximum." +#~ msgstr "" +#~ "No se permite empacar más de la cantidad, el valor se ha cambiado al " +#~ "máximo." + +#, python-format +#~ msgid "No lot found among current transfers." +#~ msgstr "No se encontró lote perteneciente a transferencias actuales." + +#, python-format +#~ msgid "No product found among current transfers." +#~ msgstr "" +#~ "No se ha encontrado ningún producto perteneciente a la transferencia " +#~ "actual." + +#, python-format +#~ msgid "Product not found in the current transfer." +#~ msgstr "Producto no encontrado en la transferencia actual." + +#, python-format +#~ msgid "This lot does not exist anymore." +#~ msgstr "Este lote ya no existe." + +#, python-format +#~ msgid "Packaging not found in the current transfer or already in a package." +#~ msgstr "" +#~ "Paquete no encontrado en la transferencia actual o ya en un paquete." + +#, python-format +#~ msgid "Product not found in the current transfer or already in a package." +#~ msgstr "" +#~ "Producto no encontrado en la transferencia actual o ya en un paquete." + +#~ msgid "" +#~ "Created from backorder %s." +#~ msgstr "" +#~ "Creado desde pedido pendiente %s." + +#~ msgid "This location content can't be moved using this menu." +#~ msgstr "El contenido de esta ubicación no se puede mover usando este menú." + +#~ msgid "%s updated." +#~ msgstr "%s actualizado." + +#~ msgid "Stock Picking" +#~ msgstr "Inventario de la Entrega" + +#~ msgid "Created by" +#~ msgstr "Creado por" + +#~ msgid "Created on" +#~ msgstr "Creado el" + +#~ msgid "Last Updated by" +#~ msgstr "Última Actualización por" + +#~ msgid "Last Updated on" +#~ msgstr "Última Actualización el" + +#~ msgid "Legacy model for tracking REST calls: replacedy by rest.log" +#~ msgstr "" +#~ "Modelo heredado para rastrear llamadas REST: reemplazado por rest.log" + +#~ msgid "Packaging {} is not allowed for carrier {}." +#~ msgstr "Empaquetado {} no está permitido para en transportista {}." + +#~ msgid "Goods packed in {}" +#~ msgstr "Producto(s) empaquetado(s) en {}" + +#~ msgid "Not a valid destination package" +#~ msgstr "No es un paquete de destino válido" + +#~ msgid "Packaging {} does not match carrier {}." +#~ msgstr "El Empaquetado {} no coincide con el transportista {}." + +#~ msgid "Active" +#~ msgstr "Activo" + +#~ msgid "Archived" +#~ msgstr "Archivado" + +#~ msgid "Group By" +#~ msgstr "Agrupar por" + +#~ msgid "Menus" +#~ msgstr "Menús" + +#~ msgid "Menus visible for this profile" +#~ msgstr "Menús visibles para este perfil" + +#~ msgid "Name" +#~ msgstr "Nombre" + +#~ msgid "Options" +#~ msgstr "Opciones" + +#~ msgid "Profile" +#~ msgstr "Perfil" + +#~ msgid "Profiles" +#~ msgstr "Perfiles" + +#~ msgid "" +#~ "Record not found.\n" +#~ "We've tried with the following types: {}" +#~ msgstr "" +#~ "Registro no encontrado.\n" +#~ "Hemos tratado con los siguientes tipos: {}" + +#~ msgid "Scenario" +#~ msgstr "Escenario" + +#~ msgid "Scenario Options" +#~ msgstr "Opciones de Escenario" + +#~ msgid "Sequence" +#~ msgstr "Secuencia" + +#~ msgid "Shopfloor profile settings" +#~ msgstr "Ajustes del perfil de taller" + +#~ msgid "The record %s %s does not exist" +#~ msgstr "El registro %s %s no existe" + +#~ msgid "Visible on this profile only" +#~ msgstr "Visible en este perfil solo" + +#~ msgid "Auto-vacuum Shopfloor Logs" +#~ msgstr "Eliminación Automática de Registros del Taller" + +#~ msgid "Checkout Packing Information" +#~ msgstr "Información del Paquete del Checkout" + +#~ msgid "Contact" +#~ msgstr "Contacto" + +#~ msgid "Date" +#~ msgstr "Fecha" + +#~ msgid "Display customer packing info" +#~ msgstr "Mostrar información del empaquetado de cliente" + +#~ msgid "Error" +#~ msgstr "Error" + +#~ msgid "Exception" +#~ msgstr "Excepción" + +#~ msgid "Exception Message" +#~ msgstr "Mensaje de Excepción" + +#~ msgid "Exception message" +#~ msgstr "Mensaje de Excepción" + +#~ msgid "Failed" +#~ msgstr "Fallido" + +#~ msgid "" +#~ "For the Shopfloor Checkout/Packing scenarios to display the customer " +#~ "packing info." +#~ msgstr "" +#~ "Para que los escenarios de Checkout/Empaquetado del Taller muestren la " +#~ "información de empaquetado del cliente." + +#~ msgid "Functional" +#~ msgstr "Funcional" + +#~ msgid "Functional errors" +#~ msgstr "Errores funcionales" + +#~ msgid "Headers" +#~ msgstr "Cabeceras" + +#~ msgid "Logs generated today" +#~ msgstr "Registros generados hoy" + +#~ msgid "Packing information" +#~ msgstr "Información del empaquetado" + +#~ msgid "Parameters" +#~ msgstr "Parámetros" + +#~ msgid "Params" +#~ msgstr "Parámetros" + +#~ msgid "Request Method" +#~ msgstr "Método del Request" + +#~ msgid "Request URL" +#~ msgstr "URL del Request" + +#~ msgid "Result" +#~ msgstr "Resultado" + +#~ msgid "Severe" +#~ msgstr "Severo" + +#~ msgid "Severe errors" +#~ msgstr "Errores severos" + +#~ msgid "Severity" +#~ msgstr "Severidad" + +#~ msgid "Shopfloor Logging" +#~ msgstr "Registro del Taller" + +#~ msgid "Shopfloor Logs" +#~ msgstr "Registros del Taller" + +#~ msgid "State" +#~ msgstr "Estado" + +#~ msgid "Status" +#~ msgstr "Estado" + +#~ msgid "Success" +#~ msgstr "Satisfactorio" + +#~ msgid "Today" +#~ msgstr "Hoy" + +#~ msgid "User" +#~ msgstr "Usuario" + +#~ msgid "Warning" +#~ msgstr "Advertencia" + +#~ msgid "Warning errors" +#~ msgstr "Errores de advertencia" diff --git a/shopfloor/i18n/pt_BR.po b/shopfloor/i18n/pt_BR.po new file mode 100644 index 0000000000..af2e46e5cc --- /dev/null +++ b/shopfloor/i18n/pt_BR.po @@ -0,0 +1,1791 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "" +"\n" +"If you tick this box, while picking goods from a location\n" +"(eg: zone picking) set destination will work as follow:\n" +"\n" +"* if a location is scanned, a new delivery package is created;\n" +"* if a package is scanned, the package is validated against the carrier\n" +"* in both cases, if the picking has no carrier the operation fails.\",\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__no_prefill_qty +msgid "" +"\n" +"We assume the picker will take the suggested quantities.\n" +"With this option, the operator will have to enter the quantity manually or\n" +"by scanning a product or product packaging EAN to increase the quantity\n" +"(i.e. +1 Unit or +1 Box)\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_return +msgid "" +"\n" +"When enabled, you can receive unplanned products that are returned\n" +"from an existing delivery matched on the origin (SO name).\n" +"A new move will be added as a return of the delivery,\n" +"decreasing the delivered quantity of the related SO line.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__multiple_move_single_pack +msgid "" +"\n" +"When picking a move,\n" +"allow to set a destination package that was already used for the other " +"lines.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__auto_post_line +msgid "" +"\n" +"When setting result pack & destination,\n" +"automatically post the corresponding line\n" +"if this option is checked.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__unload_package_at_destination +msgid "" +"\n" +"With this option, the lines you process by putting on a package during the\n" +"picking process will be put as bulk products at the final destination " +"location.\n" +"\n" +"This is useful if your picking device is emptied at the destination location " +"or\n" +"if you want to provide bulk products to the next operation.\n" +"\n" +"Incompatible with: \"Pick and pack at the same time\"\n" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"'Pick and pack at the same time' is incompatible with 'Multiple moves same " +"destination package'." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"'Pick and pack at the same time' is incompatible with 'Unload package at " +"destination'." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_app +msgid "A Shopfloor application" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "A destination package is required." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "A draft inventory has been created for control." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "Activate Zero Check" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin +msgid "Adds shopfloor priority/postpone fields" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "All packages processed." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_alternative_destination_is_possible +msgid "Allow Alternative Destination Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation_is_possible +msgid "Allow Force Reservation Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_get_work_is_possible +msgid "Allow Get Work Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create +msgid "Allow Move Creation" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_return_is_possible +msgid "Allow Return Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_return +msgid "Allow create returns" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "Allow to process reserved quantities" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_alternative_destination +msgid "Allow to scan alternative destination locations" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Are you sure?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__auto_post_line_is_possible +msgid "Auto Post Line Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__auto_post_line +msgid "Automatically post line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode does not match with {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode not found" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_batch +msgid "Batch Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer line done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Bin %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__bulk_line_count +msgid "Bulk Line Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Canceled, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Cannot change to lot {} which is entirely picked." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__category +msgid "Category" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_checkout +#: model:shopfloor.scenario,name:shopfloor.scenario_checkout +#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo +msgid "Checkout" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_cluster_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_cluster_picking +#: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo +msgid "Cluster Picking" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__multiple_move_single_pack +msgid "Collect multiple moves on a same destination package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Confirm location change from %s to %s?" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transfer to {} completed" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transferred from %(location_name)s to %(location_dest_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Control stock issue in location {} for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Create new PACK {}? Scan it again to confirm." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Creation of moves is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__date_planned +msgid "Date Scheduled" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_delivery +#: model:shopfloor.scenario,name:shopfloor.scenario_delivery +#: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo +msgid "Delivery" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Delivery package type scanned: %(name)s. Scan again to place all goods in " +"the same package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__disable_full_bin_action_is_possible +msgid "Disable Full Bin Action Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__disable_full_bin_action +msgid "Disable full bin action" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__show_oneline_package_content +msgid "Display the content of package if it contains 1 line only" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__no_prefill_qty +msgid "Do not pre-fill quantity to pick" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "" +"For Shopfloor scenarios using it (Cluster Picking, Zone Picking, Discrete " +"order Picking), the zero check step will be activated when a location " +"becomes empty after a move." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation +msgid "Force stock reservation" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id +msgid "From" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Goods packed into {0.name}" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__id +msgid "ID" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "" +"If you tick this box, the transfer is reserved only if the put-away can find " +"a sublocation (when putaway destination is different from the operation " +"type's destination)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "" +"If you tick this box, this scenario will allow operator to move goods even " +"if a reservation is made by a different operation type." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible +msgid "Ignore No Putaway Available Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "Ignore transfers when no put-away is available" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Ignoring not found putaway is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_inventory +msgid "Inventory" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_location +msgid "Inventory Locations" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_location____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/completion_info.py:0 +#, python-format +msgid "Last operation of transfer %(picking_names)s. Next operation (%(next_picking_names)s) is ready to " +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Line cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lines have different destination location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location %s doesn't contain any package." +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_location_content_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo +msgid "Location Content Transfer" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_location_content_transfer +msgid "Location content transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location empty. Try scanning a package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location not allowed here." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location {} empty" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Lot is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Lot {lot} for product {product} found in multiple locations. Scan your " +"location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Lot {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot {} is for another product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot %(old_lot_name)s replaced by lot %(new_lot_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Lot: " +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Missing expiration date." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible +msgid "Move Create Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids +msgid "Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count +msgid "Move Line Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Move already processed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Move lines processed have to share the same source location." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__multiple_move_single_pack_is_possible +msgid "Multiple Move Single Pack Is Possible" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Negative quantity not allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "New move lines cannot be assigned: canceled." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__no_prefill_qty_is_possible +msgid "No Prefill Qty Is Possible" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No available work could be found." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No delivery package type available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No line to pack found." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lines to process." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No location found for this barcode." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lot found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "No more work to do, please create a new batch transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation type found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/service.py:0 +#, python-format +msgid "No operation types configured on menu {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No package or lot found for barcode {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No pending operation for package %s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found in {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No putaway destination is available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No quantity has been processed, unable to complete the transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for the scanned lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for the scanned packaging." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for this lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for this product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No valid package to select." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No value" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Not all lines have been processed with full quantity. Do you confirm partial " +"operation?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids +msgid "Operation Types" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation already processed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Operation types for this menu are missing default source and destination " +"locations." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation's already running. Would you like to take it over?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__package_id +msgid "Package" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__package_level_count +msgid "Package Level Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package has been opened. You can move partial quantities." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Package level has to be in draft" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_quant_package.py:0 +#, python-format +msgid "Package name must be unique!" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Package {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s cannot be picked, already moved by transfer %(picking_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s cannot be used: %(error)s" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "" +"Package %(package_name)s does not contain available product %(product_name)s, cannot replace package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} has a different content." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Package {} has been partially picked in another location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is already used." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s is not available in transfer %(picking_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is not empty." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package {} is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(old_package_name)s replaced by package %(new_package_name)s." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant_package +msgid "Packages" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging '%(package_name)s' is not allowed for carrier %(carrier_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Packaging changed on package {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging not found in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Pick + Pack mode ON: the picking {0.name} has no carrier set. The system " +"couldn't pack goods automatically." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time_is_possible +msgid "Pick Pack Same Time Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "Pick and pack at the same time" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "Pick: stock issue on lot: %(lot_name)s found in %(location_name)s" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__picking_id +msgid "Picking" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count +msgid "Picking Count" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_type +msgid "Picking Type" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking has already been started in this location in transfer(s): {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking type {} complete." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Place it in {}?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Planned Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Please note that the scanned quantity is higher than the maximum allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Please scan the location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Please scan the package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__prepackaged_product_is_possible +msgid "Prepackaged Product Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_prepackaged_product +msgid "Process as pre-packaged" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Processing reserved quantities is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product tracked by lot, please scan one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Product {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product {} found in multiple locations. Scan your location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product(s) processed as raw product(s)" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant +msgid "Quants" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Real pack weight or the estimated one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Recovered previous session." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Remaining raw product not packed, proceed anyway?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids +msgid "Reserved Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Restart the operation, someone has canceled it." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF Priority" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF User" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF checkout done" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF unloaded" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Same package {} is already assigned." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scan_location_or_pack_first_is_possible +msgid "Scan Location Or Pack First Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scan_location_or_pack_first +msgid "Scan first location or pack" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Scan the destination location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Scan the package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"Scenario `%(scenario_name)s` require(s) 'Move Entire Packages' to be enabled.\n" +"These type(s) do not satisfy this constraint: \n" +"%(bad_picking_types)s.\n" +"Please, adjust your configuration." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__date_planned +msgid "Scheduled date until move is done, then date of actual move processing" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan a lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan the lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several moves found for different lots, please scan the lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Several moves found on different locations, please scan first the location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several operation types found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several packages found in %s, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan a product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan the product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "" +"Several transfers found, please scan a package or select a transfer manually." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several transfers found, please select a transfer manually." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form +#: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form +msgid "Shopfloor" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done +msgid "Shopfloor Checkout Done" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids +msgid "Shopfloor Menus" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "Shopfloor Picking Sequence" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "Shopfloor Postponed" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Shopfloor Priority" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded +msgid "Shopfloor Unloaded" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id +msgid "Shopfloor User" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.app,name:shopfloor.app_demo +msgid "Shopfloor WMS (demo)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Shopfloor weight (kg)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_get_work +msgid "Show Get Work on start" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__show_oneline_package_content_is_possible +msgid "Show Oneline Package Content Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__show_oneline_package_content +msgid "Show one-line package content" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_single_pack_transfer +msgid "Single Pack Transfer" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_single_pallet_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo +msgid "Single Pallet Transfer" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create +msgid "" +"Some scenario may create move(s) when a product or package is scanned and no " +"move already exists. Any new move is created in the selected operation type, " +"so it can be active only when one type is selected." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/stock.py:0 +#, python-format +msgid "Someone is already working on these transfers" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids +msgid "Source Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id +msgid "Source Package" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_package_level +msgid "Stock Package Level" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "" +"Technical field. Indicates if the operation has been postponed in a barcode " +"scenario." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count +msgid "Technical field. Indicates number of move lines included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__bulk_line_count +msgid "" +"Technical field. Indicates number of move lines without package included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__package_level_count +msgid "Technical field. Indicates number of package_level included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count +msgid "Technical field. Indicates number of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight +msgid "Technical field. Indicates total weight of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Technical field. Move lines for which destination is this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids +msgid "Technical field. Move lines moving this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Technical field. Overrides operation priority in barcode scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_picking.py:0 +#, python-format +msgid "" +"The backorder %s has been created." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The content of {} cannot be transferred with this scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "The destination bin {} is not empty, please take another." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The pack has been moved, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s cannot be transferred with this scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't contain any product to take." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The picked quantity must be a value above zero." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "" +"The picking done in Shopfloor scenarios will respect this order. The " +"sequence is a char so it can be composed of fields such as 'corridor-rack-" +"side-level'. Pay attention to the padding ('09' is before '19', '9' is not). " +"It is recommended to use an Export then an Import to populate this field " +"using a spreadsheet." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The product/packaging you selected has already been returned." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"The quantity scanned for one or more lines cannot be higher than the maximum " +"allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The record you were working on does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move.py:0 +#, python-format +msgid "The split order {} has been created." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id +msgid "The stock operation where the packing has been made" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "This batch cannot be selected." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line has a package, please select the package instead." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line is not available in transfer {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "This location content can't be moved at once." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location does not exist." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location only contains packages, please scan one of them." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location requires packages. Please scan a destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This lot is part of a package with other products, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This lot is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This operation does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This package does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This product is part of a package with other products, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of a package, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This source document is part of multiple transfers, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This transfer does not exist or is not available anymore." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight +msgid "Total Weight" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id +msgid "Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} is not available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Units replaced by package {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unload_package_at_destination_is_possible +msgid "Unload Package At Destination Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unload_package_at_destination +msgid "Unload package at destination" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Unrecoverable error, please restart." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible +msgid "Unreserve Other Moves Is Possible" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.profile,name:shopfloor.profile_demo_2 +msgid "WH delivery" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.profile,name:shopfloor.profile_demo_1 +msgid "WH worker" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_app__category__wms +msgid "WMS" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.app,short_name:shopfloor.app_demo +msgid "WMS (demo)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_prepackaged_product +msgid "" +"When active, what you scan (typically a product packaging EAN) will be ship " +"'as-is' and the operation will be validated triggering a backorder creation " +"with the remaining lines." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_get_work +msgid "" +"When enabled the user will have the option to ask for a task to work on." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_alternative_destination +msgid "" +"When enabled the user will have the option to scan destination locations " +"other than the expected ones (ask for confirmation)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__disable_full_bin_action +msgid "When picking, prevent unloading the whole bin when full." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__scan_location_or_pack_first +msgid "" +"When selecting work, force the user to first scan a location or pack,then " +"the product or lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Working location changed to {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "Wrong bin" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong packaging." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot move this using this menu." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot place it here" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot return more quantity than what was initially sent." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot work on a package (%s) outside of locations: %s" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You must not pick more than {} units." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"You scanned a different package with the same product, do you want to change " +"pack? Scan it again to confirm" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {} ({})" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_zone_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_zone_picking +#: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo +msgid "Zone Picking" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "" +"{picking.name} stock correction in location {location.name} for " +"{product_desc}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} is not a valid destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} not found in the current transfer or already in a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "%(qty)s %(product_name)s put in %(package_name)s" +msgstr "" diff --git a/shopfloor/i18n/shopfloor.pot b/shopfloor/i18n/shopfloor.pot new file mode 100644 index 0000000000..3a2ece59d5 --- /dev/null +++ b/shopfloor/i18n/shopfloor.pot @@ -0,0 +1,1791 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "" +"\n" +"If you tick this box, while picking goods from a location\n" +"(eg: zone picking) set destination will work as follow:\n" +"\n" +"* if a location is scanned, a new delivery package is created;\n" +"* if a package is scanned, the package is validated against the carrier\n" +"* in both cases, if the picking has no carrier the operation fails.\",\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__no_prefill_qty +msgid "" +"\n" +"We assume the picker will take the suggested quantities.\n" +"With this option, the operator will have to enter the quantity manually or\n" +"by scanning a product or product packaging EAN to increase the quantity\n" +"(i.e. +1 Unit or +1 Box)\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_return +msgid "" +"\n" +"When enabled, you can receive unplanned products that are returned\n" +"from an existing delivery matched on the origin (SO name).\n" +"A new move will be added as a return of the delivery,\n" +"decreasing the delivered quantity of the related SO line.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__multiple_move_single_pack +msgid "" +"\n" +"When picking a move,\n" +"allow to set a destination package that was already used for the other lines.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__auto_post_line +msgid "" +"\n" +"When setting result pack & destination,\n" +"automatically post the corresponding line\n" +"if this option is checked.\n" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__unload_package_at_destination +msgid "" +"\n" +"With this option, the lines you process by putting on a package during the\n" +"picking process will be put as bulk products at the final destination location.\n" +"\n" +"This is useful if your picking device is emptied at the destination location or\n" +"if you want to provide bulk products to the next operation.\n" +"\n" +"Incompatible with: \"Pick and pack at the same time\"\n" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"'Pick and pack at the same time' is incompatible with 'Multiple moves same " +"destination package'." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"'Pick and pack at the same time' is incompatible with 'Unload package at " +"destination'." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_app +msgid "A Shopfloor application" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "A destination package is required." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "A draft inventory has been created for control." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "Activate Zero Check" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_priority_postpone_mixin +msgid "Adds shopfloor priority/postpone fields" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "All packages processed." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_alternative_destination_is_possible +msgid "Allow Alternative Destination Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation_is_possible +msgid "Allow Force Reservation Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_get_work_is_possible +msgid "Allow Get Work Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_move_create +msgid "Allow Move Creation" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_return_is_possible +msgid "Allow Return Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_return +msgid "Allow create returns" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "Allow to process reserved quantities" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_alternative_destination +msgid "Allow to scan alternative destination locations" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Are you sure?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__auto_post_line_is_possible +msgid "Auto Post Line Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__auto_post_line +msgid "Automatically post line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode does not match with {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Barcode not found" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_batch +msgid "Batch Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Batch Transfer line done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Bin %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__bulk_line_count +msgid "Bulk Line Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Canceled, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Cannot change to lot {} which is entirely picked." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__category +msgid "Category" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_checkout +#: model:shopfloor.scenario,name:shopfloor.scenario_checkout +#: model:stock.picking.type,name:shopfloor.picking_type_checkout_demo +msgid "Checkout" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_cluster_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_cluster_picking +#: model:stock.picking.type,name:shopfloor.picking_type_cluster_picking_demo +msgid "Cluster Picking" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__multiple_move_single_pack +msgid "Collect multiple moves on a same destination package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Confirm location change from %s to %s?" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transfer to {} completed" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Content transferred from %(location_name)s to %(location_dest_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Control stock issue in location {} for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Create new PACK {}? Scan it again to confirm." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Creation of moves is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__date_planned +msgid "Date Scheduled" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_delivery +#: model:shopfloor.scenario,name:shopfloor.scenario_delivery +#: model:stock.picking.type,name:shopfloor.picking_type_delivery_demo +msgid "Delivery" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Delivery package type scanned: %(name)s. Scan again to place all goods in " +"the same package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__disable_full_bin_action_is_possible +msgid "Disable Full Bin Action Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__disable_full_bin_action +msgid "Disable full bin action" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__display_name +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__display_name +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__show_oneline_package_content +msgid "Display the content of package if it contains 1 line only" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__no_prefill_qty +msgid "Do not pre-fill quantity to pick" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_type__shopfloor_zero_check +msgid "" +"For Shopfloor scenarios using it (Cluster Picking, Zone Picking, Discrete " +"order Picking), the zero check step will be activated when a location " +"becomes empty after a move." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_force_reservation +msgid "Force stock reservation" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__location_id +msgid "From" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Goods packed into {0.name}" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__id +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant__id +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__id +msgid "ID" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "" +"If you tick this box, the transfer is reserved only if the put-away can find" +" a sublocation (when putaway destination is different from the operation " +"type's destination)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_unreserve_other_moves +msgid "" +"If you tick this box, this scenario will allow operator to move goods even " +"if a reservation is made by a different operation type." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available_is_possible +msgid "Ignore No Putaway Available Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__ignore_no_putaway_available +msgid "Ignore transfers when no put-away is available" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Ignoring not found putaway is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_inventory +msgid "Inventory" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_location +msgid "Inventory Locations" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_app____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu____last_update +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_inventory____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_location____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant____last_update +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/completion_info.py:0 +#, python-format +msgid "" +"Last operation of transfer %(picking_names)s. Next operation (%(next_picking_names)s) is ready to " +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Line cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lines have different destination location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location %s doesn't contain any package." +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_location_content_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_location_content_transfer_demo +msgid "Location Content Transfer" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_location_content_transfer +msgid "Location content transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location empty. Try scanning a package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location not allowed here." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Location {} empty" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Lot is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Lot {lot} for product {product} found in multiple locations. Scan your " +"location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Lot {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot {} is for another product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Lot %(old_lot_name)s replaced by lot %(new_lot_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Lot: " +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Missing expiration date." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__move_create_is_possible +msgid "Move Create Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__move_line_ids +msgid "Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__move_line_count +msgid "Move Line Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Move already processed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Move lines processed have to share the same source location." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__multiple_move_single_pack_is_possible +msgid "Multiple Move Single Pack Is Possible" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Negative quantity not allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "New move lines cannot be assigned: canceled." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__no_prefill_qty_is_possible +msgid "No Prefill Qty Is Possible" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No available work could be found." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No delivery package type available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No line to pack found." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lines to process." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No location found for this barcode." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No lot found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "No more work to do, please create a new batch transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No operation type found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/service.py:0 +#, python-format +msgid "No operation types configured on menu {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No package or lot found for barcode {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No pending operation for package %s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found for {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No product found in {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No putaway destination is available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No quantity has been processed, unable to complete the transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for the scanned lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for the scanned packaging." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for this lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No transfer found for this product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No valid package to select." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "No value" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Not all lines have been processed with full quantity. Do you confirm partial" +" operation?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__picking_type_ids +msgid "Operation Types" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation already processed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Operation types for this menu are missing default source and destination " +"locations." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Operation's already running. Would you like to take it over?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__package_id +msgid "Package" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__package_level_count +msgid "Package Level Count" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package cancelled" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package has been opened. You can move partial quantities." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Package level has to be in draft" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_quant_package.py:0 +#, python-format +msgid "Package name must be unique!" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Package {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s cannot be picked, already moved by transfer %(picking_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s cannot be used: %(error)s" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "" +"Package %(package_name)s does not contain available product %(product_name)s, cannot replace package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} has a different content." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move_line.py:0 +#, python-format +msgid "Package {} has been partially picked in another location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is already used." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(package_name)s is not available in transfer %(picking_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package {} is not empty." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Package {} is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Package %(old_package_name)s replaced by package %(new_package_name)s." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant_package +msgid "Packages" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging '%(package_name)s' is not allowed for carrier %(carrier_name)s." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Packaging changed on package {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Packaging not found in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Pick + Pack mode ON: the picking {0.name} has no carrier set. The system " +"couldn't pack goods automatically." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time_is_possible +msgid "Pick Pack Same Time Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__pick_pack_same_time +msgid "Pick and pack at the same time" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/change_package_lot.py:0 +#, python-format +msgid "Pick: stock issue on lot: %(lot_name)s found in %(location_name)s" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__picking_id +msgid "Picking" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__picking_count +msgid "Picking Count" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking_type +msgid "Picking Type" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking has already been started in this location in transfer(s): {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Picking type {} complete." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Place it in {}?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Planned Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Please note that the scanned quantity is higher than the maximum allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Please scan the location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Please scan the package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__prepackaged_product_is_possible +msgid "Prepackaged Product Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_prepackaged_product +msgid "Process as pre-packaged" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "Processing reserved quantities is not allowed for menu {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product is not in the current transfer." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product tracked by lot, please scan one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/delivery.py:0 +#, python-format +msgid "Product {} belongs to a picking without a valid state." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Product {} found in multiple locations. Scan your location first." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Product(s) processed as raw product(s)" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_quant +msgid "Quants" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Real pack weight or the estimated one." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Recovered previous session." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "Remaining raw product not packed, proceed anyway?" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__reserved_move_line_ids +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__reserved_move_line_ids +msgid "Reserved Move Line" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Restart the operation, someone has canceled it." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF Priority" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF User" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF checkout done" +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_stock_move_line_detailed_operation_tree +msgid "SF unloaded" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Same package {} is already assigned." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scan_location_or_pack_first_is_possible +msgid "Scan Location Or Pack First Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__scan_location_or_pack_first +msgid "Scan first location or pack" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Scan the destination location" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "Scan the package" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/shopfloor_menu.py:0 +#, python-format +msgid "" +"Scenario `%(scenario_name)s` require(s) 'Move Entire Packages' to be enabled.\n" +"These type(s) do not satisfy this constraint: \n" +"%(bad_picking_types)s.\n" +"Please, adjust your configuration." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__date_planned +msgid "Scheduled date until move is done, then date of actual move processing" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan a lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several lots found in %s, please scan the lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several moves found for different lots, please scan the lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"Several moves found on different locations, please scan first the location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several operation types found for this menu and profile." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several packages found in %s, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan a product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several products found in %s, please scan the product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/checkout.py:0 +#, python-format +msgid "" +"Several transfers found, please scan a package or select a transfer " +"manually." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Several transfers found, please select a transfer manually." +msgstr "" + +#. module: shopfloor +#: model_terms:ir.ui.view,arch_db:shopfloor.view_location_form +#: model_terms:ir.ui.view,arch_db:shopfloor.view_picking_type_form +msgid "Shopfloor" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_checkout_done +msgid "Shopfloor Checkout Done" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_type__shopfloor_menu_ids +msgid "Shopfloor Menus" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "Shopfloor Picking Sequence" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "Shopfloor Postponed" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,field_description:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Shopfloor Priority" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_unloaded +msgid "Shopfloor Unloaded" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__shopfloor_user_id +msgid "Shopfloor User" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.app,name:shopfloor.app_demo +msgid "Shopfloor WMS (demo)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_quant_package__shopfloor_weight +msgid "Shopfloor weight (kg)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__allow_get_work +msgid "Show Get Work on start" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__show_oneline_package_content_is_possible +msgid "Show Oneline Package Content Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__show_oneline_package_content +msgid "Show one-line package content" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.scenario,name:shopfloor.scenario_single_pack_transfer +msgid "Single Pack Transfer" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_single_pallet_transfer +#: model:stock.picking.type,name:shopfloor.picking_type_single_pallet_transfer_demo +msgid "Single Pallet Transfer" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_move_create +msgid "" +"Some scenario may create move(s) when a product or package is scanned and no" +" move already exists. Any new move is created in the selected operation " +"type, so it can be active only when one type is selected." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/stock.py:0 +#, python-format +msgid "Someone is already working on these transfers" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_location__source_move_line_ids +msgid "Source Move Line" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__package_id +msgid "Source Package" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_move +msgid "Stock Move" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_package_level +msgid "Stock Package Level" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_postponed +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_postponed +msgid "" +"Technical field. Indicates if the operation has been postponed in a barcode " +"scenario." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__move_line_count +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__move_line_count +msgid "Technical field. Indicates number of move lines included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__bulk_line_count +msgid "" +"Technical field. Indicates number of move lines without package included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__package_level_count +msgid "Technical field. Indicates number of package_level included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__picking_count +msgid "Technical field. Indicates number of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,help:shopfloor.field_stock_picking_batch__total_weight +msgid "Technical field. Indicates total weight of transfers included." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__planned_move_line_ids +msgid "Technical field. Move lines for which destination is this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_quant_package__move_line_ids +msgid "Technical field. Move lines moving this package." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_priority_postpone_mixin__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__shopfloor_priority +#: model:ir.model.fields,help:shopfloor.field_stock_package_level__shopfloor_priority +msgid "Technical field. Overrides operation priority in barcode scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_picking.py:0 +#, python-format +msgid "" +"The backorder %s has been created." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The content of {} cannot be transferred with this scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "The destination bin {} is not empty, please take another." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The pack has been moved, you can scan a new pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s cannot be transferred with this scenario." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't contain any product to take." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The package %s doesn't exist" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The picked quantity must be a value above zero." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_location__shopfloor_picking_sequence +msgid "" +"The picking done in Shopfloor scenarios will respect this order. The " +"sequence is a char so it can be composed of fields such as 'corridor-rack-" +"side-level'. Pay attention to the padding ('09' is before '19', '9' is not)." +" It is recommended to use an Export then an Import to populate this field " +"using a spreadsheet." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The product/packaging you selected has already been returned." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"The quantity scanned for one or more lines cannot be higher than the maximum" +" allowed." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "The record you were working on does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/models/stock_move.py:0 +#, python-format +msgid "The split order {} has been created." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_stock_move_line__picking_id +msgid "The stock operation where the packing has been made" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "This batch cannot be selected." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line has a package, please select the package instead." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This line is not available in transfer {}." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/location_content_transfer.py:0 +#, python-format +msgid "This location content can't be moved at once." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location does not exist." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location only contains packages, please scan one of them." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This location requires packages. Please scan a destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This lot is part of a package with other products, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This lot is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This operation does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This package does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product does not exist anymore." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This product is part of a package with other products, please scan a " +"package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of a package, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This product is part of multiple packages, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"This source document is part of multiple transfers, please scan a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "This transfer does not exist or is not available anymore." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking__total_weight +#: model:ir.model.fields,field_description:shopfloor.field_stock_picking_batch__total_weight +msgid "Total Weight" +msgstr "" + +#. module: shopfloor +#: model:ir.model,name:shopfloor.model_stock_picking +#: model:ir.model.fields,field_description:shopfloor.field_stock_move_line__picking_id +msgid "Transfer" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} complete" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} done" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Transfer {} is not available." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Units replaced by package {}." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unload_package_at_destination_is_possible +msgid "Unload Package At Destination Is Possible" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unload_package_at_destination +msgid "Unload package at destination" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Unrecoverable error, please restart." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,field_description:shopfloor.field_shopfloor_menu__unreserve_other_moves_is_possible +msgid "Unreserve Other Moves Is Possible" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.profile,name:shopfloor.profile_demo_2 +msgid "WH delivery" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.profile,name:shopfloor.profile_demo_1 +msgid "WH worker" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields.selection,name:shopfloor.selection__shopfloor_app__category__wms +msgid "WMS" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.app,short_name:shopfloor.app_demo +msgid "WMS (demo)" +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_prepackaged_product +msgid "" +"When active, what you scan (typically a product packaging EAN) will be ship " +"'as-is' and the operation will be validated triggering a backorder creation " +"with the remaining lines." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_get_work +msgid "" +"When enabled the user will have the option to ask for a task to work on." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__allow_alternative_destination +msgid "" +"When enabled the user will have the option to scan destination locations " +"other than the expected ones (ask for confirmation)." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__disable_full_bin_action +msgid "When picking, prevent unloading the whole bin when full." +msgstr "" + +#. module: shopfloor +#: model:ir.model.fields,help:shopfloor.field_shopfloor_menu__scan_location_or_pack_first +msgid "" +"When selecting work, force the user to first scan a location or pack,then " +"the product or lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Working location changed to {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/services/cluster_picking.py:0 +#, python-format +msgid "Wrong bin" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong location." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong lot." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong pack." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong packaging." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong product." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "Wrong." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot move this using this menu." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot place it here" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot return more quantity than what was initially sent." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You cannot work on a package (%s) outside of locations: %s" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "You must not pick more than {} units." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "" +"You scanned a different package with the same product, do you want to change" +" pack? Scan it again to confirm" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "Zero check issue on location {} ({})" +msgstr "" + +#. module: shopfloor +#: model:shopfloor.menu,name:shopfloor.shopfloor_menu_demo_zone_picking +#: model:shopfloor.scenario,name:shopfloor.scenario_zone_picking +#: model:stock.picking.type,name:shopfloor.picking_type_zone_picking_demo +msgid "Zone Picking" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/inventory.py:0 +#, python-format +msgid "" +"{picking.name} stock correction in location {location.name} for " +"{product_desc}" +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} is not a valid destination package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "{} not found in the current transfer or already in a package." +msgstr "" + +#. module: shopfloor +#: code:addons/shopfloor/actions/message.py:0 +#, python-format +msgid "%(qty)s %(product_name)s put in %(package_name)s" +msgstr "" diff --git a/shopfloor/models/__init__.py b/shopfloor/models/__init__.py new file mode 100644 index 0000000000..7d3212690d --- /dev/null +++ b/shopfloor/models/__init__.py @@ -0,0 +1,12 @@ +from . import priority_postpone_mixin +from . import shopfloor_menu +from . import shopfloor_app +from . import stock_picking_type +from . import stock_location +from . import stock_move +from . import stock_move_line +from . import stock_package_level +from . import stock_picking +from . import stock_picking_batch +from . import stock_quant +from . import stock_quant_package diff --git a/shopfloor/models/priority_postpone_mixin.py b/shopfloor/models/priority_postpone_mixin.py new file mode 100644 index 0000000000..cbe64cdfbf --- /dev/null +++ b/shopfloor/models/priority_postpone_mixin.py @@ -0,0 +1,41 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class PriorityPostponeMixin(models.AbstractModel): + _name = "shopfloor.priority.postpone.mixin" + _description = "Adds shopfloor priority/postpone fields" + + _SF_PRIORITY_DEFAULT = 10 + + shopfloor_priority = fields.Integer( + default=lambda self: self._SF_PRIORITY_DEFAULT, + copy=False, + help="Technical field. Overrides operation priority in barcode scenario.", + ) + shopfloor_postponed = fields.Boolean( + copy=False, + help="Technical field. " + "Indicates if the operation has been postponed in a barcode scenario.", + ) + + def _get_max_shopfloor_priority(self, records): + self.ensure_one() + if not records: + return 0 + return max(rec.shopfloor_priority for rec in records) + + def shopfloor_postpone(self, *recordsets): + """Postpone the record and update its priority based on other records. + + The method accepts several recordsets as parameter (to be able to get + the current max priority from different types of records). + """ + self.ensure_one() + # Set the max priority from sibling records + 1 + max_priority = max( + self._get_max_shopfloor_priority(records) for records in recordsets + ) + self.shopfloor_priority = max_priority + 1 + self.shopfloor_postponed = True diff --git a/shopfloor/models/shopfloor_app.py b/shopfloor/models/shopfloor_app.py new file mode 100644 index 0000000000..cadceb891b --- /dev/null +++ b/shopfloor/models/shopfloor_app.py @@ -0,0 +1,9 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ShopfloorApp(models.Model): + _inherit = "shopfloor.app" + + category = fields.Selection(selection_add=[("wms", "WMS")]) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py new file mode 100644 index 0000000000..bf4af32032 --- /dev/null +++ b/shopfloor/models/shopfloor_menu.py @@ -0,0 +1,436 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, api, exceptions, fields, models + +PICK_PACK_SAME_TIME_HELP = """ +If you tick this box, while picking goods from a location +(eg: zone picking) set destination will work as follow: + +* if a location is scanned, a new delivery package is created; +* if a package is scanned, the package is validated against the carrier +* in both cases, if the picking has no carrier the operation fails.", +""" + +UNLOAD_PACK_AT_DEST_HELP = """ +With this option, the lines you process by putting on a package during the +picking process will be put as bulk products at the final destination location. + +This is useful if your picking device is emptied at the destination location or +if you want to provide bulk products to the next operation. + +Incompatible with: "Pick and pack at the same time" +""" + +MULTIPLE_MOVE_SINGLE_PACK_HELP = """ +When picking a move, +allow to set a destination package that was already used for the other lines. +""" + +NO_PREFILL_QTY_HELP = """ +We assume the picker will take the suggested quantities. +With this option, the operator will have to enter the quantity manually or +by scanning a product or product packaging EAN to increase the quantity +(i.e. +1 Unit or +1 Box) +""" + +AUTO_POST_LINE = """ +When setting result pack & destination, +automatically post the corresponding line +if this option is checked. +""" + +RETURN_HELP = """ +When enabled, you can receive unplanned products that are returned +from an existing delivery matched on the origin (SO name). +A new move will be added as a return of the delivery, +decreasing the delivered quantity of the related SO line. +""" + + +class ShopfloorMenu(models.Model): + _inherit = "shopfloor.menu" + + picking_type_ids = fields.Many2many( + comodel_name="stock.picking.type", string="Operation Types", required=True + ) + move_create_is_possible = fields.Boolean(compute="_compute_move_create_is_possible") + # only available for some scenarios, move_create_is_possible defines if the option + # can be used or not + allow_move_create = fields.Boolean( + string="Allow Move Creation", + default=False, + help="Some scenario may create move(s) when a product or package is" + " scanned and no move already exists. Any new move is created in the" + " selected operation type, so it can be active only when one type is selected.", + ) + unreserve_other_moves_is_possible = fields.Boolean( + compute="_compute_unreserve_other_moves_is_possible" + ) + allow_unreserve_other_moves = fields.Boolean( + string="Allow to process reserved quantities", + default=False, + help="If you tick this box, this scenario will allow operator to move" + " goods even if a reservation is made by a different operation type.", + ) + ignore_no_putaway_available_is_possible = fields.Boolean( + compute="_compute_ignore_no_putaway_available_is_possible" + ) + ignore_no_putaway_available = fields.Boolean( + string="Ignore transfers when no put-away is available", + default=False, + help="If you tick this box, the transfer is reserved only " + "if the put-away can find a sublocation (when putaway destination " + "is different from the operation type's destination).", + ) + prepackaged_product_is_possible = fields.Boolean( + compute="_compute_prepackaged_product_is_possible" + ) + allow_prepackaged_product = fields.Boolean( + string="Process as pre-packaged", + default=False, + help=( + "When active, what you scan (typically a product packaging EAN) " + "will be ship 'as-is' and the operation will be validated " + "triggering a backorder creation with the remaining lines." + ), + ) + # TODO: refactor handling of these options. + # Possible solution: + # * field should stay on the scenario and get stored in options + # * field should use `sf_scenario` (eg: sf_scenario=("zone_picking", )) + # to control for which scenario it will be available + # * on the menu form, display a button to edit configurations + # and display a summary + pick_pack_same_time = fields.Boolean( + string="Pick and pack at the same time", + default=False, + help=PICK_PACK_SAME_TIME_HELP, + ) + pick_pack_same_time_is_possible = fields.Boolean( + compute="_compute_pick_pack_same_time_is_possible" + ) + multiple_move_single_pack_is_possible = fields.Boolean( + compute="_compute_multiple_move_single_pack_is_possible" + ) + multiple_move_single_pack = fields.Boolean( + string="Collect multiple moves on a same destination package", + default=False, + help=MULTIPLE_MOVE_SINGLE_PACK_HELP, + ) + unload_package_at_destination_is_possible = fields.Boolean( + compute="_compute_unload_package_at_dest_is_possible" + ) + unload_package_at_destination = fields.Boolean( + string="Unload package at destination", + default=False, + help=UNLOAD_PACK_AT_DEST_HELP, + ) + + disable_full_bin_action_is_possible = fields.Boolean( + compute="_compute_disable_full_bin_action_is_possible" + ) + disable_full_bin_action = fields.Boolean( + string="Disable full bin action", + default=False, + # TODO: improve this desc w/ usecases. + help=("When picking, prevent unloading the whole bin when full."), + ) + + allow_force_reservation = fields.Boolean( + string="Force stock reservation", + default=False, + ) + allow_force_reservation_is_possible = fields.Boolean( + compute="_compute_allow_force_reservation_is_possible" + ) + + allow_get_work = fields.Boolean( + string="Show Get Work on start", + default=False, + help=( + "When enabled the user will have the option to ask " + "for a task to work on." + ), + ) + allow_get_work_is_possible = fields.Boolean( + compute="_compute_allow_get_work_is_possible" + ) + no_prefill_qty = fields.Boolean( + string="Do not pre-fill quantity to pick", + help=NO_PREFILL_QTY_HELP, + default=False, + ) + no_prefill_qty_is_possible = fields.Boolean( + compute="_compute_no_prefill_qty_is_possible" + ) + show_oneline_package_content = fields.Boolean( + string="Show one-line package content", + help="Display the content of package if it contains 1 line only", + default=False, + ) + show_oneline_package_content_is_possible = fields.Boolean( + compute="_compute_show_oneline_package_content_is_possible" + ) + scan_location_or_pack_first = fields.Boolean( + string="Scan first location or pack", + help=( + "When selecting work, force the user to first scan a location or pack," + "then the product or lot." + ), + ) + scan_location_or_pack_first_is_possible = fields.Boolean( + compute="_compute_scan_location_or_pack_first_is_possible" + ) + allow_alternative_destination = fields.Boolean( + string="Allow to scan alternative destination locations", + help=( + "When enabled the user will have the option to scan " + "destination locations other than the expected ones " + "(ask for confirmation)." + ), + default=False, + ) + allow_alternative_destination_is_possible = fields.Boolean( + compute="_compute_allow_alternative_destination_is_possible" + ) + allow_return_is_possible = fields.Boolean( + compute="_compute_allow_return_is_possible" + ) + allow_return = fields.Boolean( + string="Allow create returns", + default=False, + help=RETURN_HELP, + ) + + auto_post_line = fields.Boolean( + string="Automatically post line", + default=False, + help=AUTO_POST_LINE, + ) + auto_post_line_is_possible = fields.Boolean( + compute="_compute_auto_post_line_is_possible" + ) + + @api.onchange("unload_package_at_destination") + def _onchange_unload_package_at_destination(self): + # Uncheck pick_pack_same_time when unload_package_at_destination is set to True + for record in self: + if record.unload_package_at_destination: + record.pick_pack_same_time = False + + @api.onchange("pick_pack_same_time") + def _onchange_pick_pack_same_time(self): + # pick_pack_same_time is incompatible with multiple_move_single_pack and + # multiple_move_single_pack + for record in self: + if record.pick_pack_same_time: + record.unload_package_at_destination = False + record.multiple_move_single_pack = False + + @api.onchange("multiple_move_single_pack") + def _onchange_multiple_move_single_pack(self): + # multiple_move_single_pack is incompatible with pick_pack_same_time, + for record in self: + if record.multiple_move_single_pack: + record.pick_pack_same_time = False + + @api.constrains( + "unload_package_at_destination", + "pick_pack_same_time", + "multiple_move_single_pack", + ) + def _check_options(self): + if self.pick_pack_same_time and self.unload_package_at_destination: + raise exceptions.UserError( + _( + "'Pick and pack at the same time' is incompatible with " + "'Unload package at destination'." + ) + ) + elif self.pick_pack_same_time and self.multiple_move_single_pack: + raise exceptions.UserError( + _( + "'Pick and pack at the same time' is incompatible with " + "'Multiple moves same destination package'." + ) + ) + + @api.depends("scenario_id", "picking_type_ids") + def _compute_move_create_is_possible(self): + for menu in self: + menu.move_create_is_possible = bool( + menu.scenario_id.has_option("allow_create_moves") + and len(menu.picking_type_ids) == 1 + ) + + @api.onchange("move_create_is_possible") + def onchange_move_create_is_possible(self): + self.allow_move_create = self.move_create_is_possible + + @api.constrains("scenario_id", "picking_type_ids", "allow_move_create") + def _check_allow_move_create(self): + for menu in self: + if menu.allow_move_create and not menu.move_create_is_possible: + raise exceptions.ValidationError( + _("Creation of moves is not allowed for menu {}.").format(menu.name) + ) + + @api.depends("scenario_id") + def _compute_unreserve_other_moves_is_possible(self): + for menu in self: + menu.unreserve_other_moves_is_possible = menu.scenario_id.has_option( + "allow_unreserve_other_moves" + ) + + @api.depends("scenario_id") + def _compute_pick_pack_same_time_is_possible(self): + for menu in self: + menu.pick_pack_same_time_is_possible = menu.scenario_id.has_option( + "pick_pack_same_time" + ) + + @api.depends("scenario_id") + def _compute_unload_package_at_dest_is_possible(self): + for menu in self: + menu.unload_package_at_destination_is_possible = ( + menu.scenario_id.has_option("unload_package_at_destination") + ) + + @api.depends("scenario_id") + def _compute_multiple_move_single_pack_is_possible(self): + for menu in self: + menu.multiple_move_single_pack_is_possible = menu.scenario_id.has_option( + "multiple_move_single_pack" + ) + + @api.onchange("unreserve_other_moves_is_possible") + def onchange_unreserve_other_moves_is_possible(self): + self.allow_unreserve_other_moves = self.unreserve_other_moves_is_possible + + @api.depends("scenario_id") + def _compute_disable_full_bin_action_is_possible(self): + for menu in self: + menu.disable_full_bin_action_is_possible = menu.scenario_id.has_option( + "disable_full_bin_action" + ) + + @api.depends("scenario_id") + def _compute_ignore_no_putaway_available_is_possible(self): + for menu in self: + menu.ignore_no_putaway_available_is_possible = menu.scenario_id.has_option( + "allow_ignore_no_putaway_available" + ) + + @api.onchange("ignore_no_putaway_available_is_possible") + def onchange_ignore_no_putaway_available_is_possible(self): + self.ignore_no_putaway_available = self.ignore_no_putaway_available_is_possible + + @api.depends("scenario_id") + def _compute_prepackaged_product_is_possible(self): + for menu in self: + menu.prepackaged_product_is_possible = menu.scenario_id.has_option( + "allow_prepackaged_product" + ) + + @api.constrains("scenario_id", "picking_type_ids", "ignore_no_putaway_available") + def _check_ignore_no_putaway_available(self): + for menu in self: + if ( + menu.ignore_no_putaway_available + and not menu.ignore_no_putaway_available_is_possible + ): + raise exceptions.ValidationError( + _("Ignoring not found putaway is not allowed for menu {}.").format( + menu.name + ) + ) + + @api.constrains("scenario_id", "picking_type_ids", "allow_unreserve_other_moves") + def _check_allow_unreserve_other_moves(self): + for menu in self: + if ( + menu.allow_unreserve_other_moves + and not menu.unreserve_other_moves_is_possible + ): + raise exceptions.ValidationError( + _( + "Processing reserved quantities is" " not allowed for menu {}." + ).format(menu.name) + ) + + @api.constrains("scenario_id", "picking_type_ids") + def _check_move_entire_packages(self): + for menu in self: + # TODO: these kind of checks should be provided by the scenario itself. + bad_picking_types = [ + x.name for x in menu.picking_type_ids if not x.show_entire_packs + ] + if ( + menu.scenario_id.has_option("must_move_entire_pack") + and bad_picking_types + ): + scenario_name = menu.scenario_id.name + raise exceptions.ValidationError( + _( + "Scenario `%(scenario_name)s` require(s) " + "'Move Entire Packages' to be enabled.\n" + "These type(s) do not satisfy this constraint: " + "\n%(bad_picking_types)s.\n" + "Please, adjust your configuration.", + scenario_name=scenario_name, + bad_picking_types="\n- ".join(bad_picking_types), + ) + ) + + @api.depends("scenario_id") + def _compute_allow_force_reservation_is_possible(self): + for menu in self: + menu.allow_force_reservation_is_possible = menu.scenario_id.has_option( + "allow_force_reservation" + ) + + @api.depends("scenario_id") + def _compute_allow_get_work_is_possible(self): + for menu in self: + menu.allow_get_work_is_possible = menu.scenario_id.has_option( + "allow_get_work" + ) + + @api.depends("scenario_id") + def _compute_no_prefill_qty_is_possible(self): + for menu in self: + menu.no_prefill_qty_is_possible = menu.scenario_id.has_option( + "no_prefill_qty" + ) + + @api.depends("scenario_id") + def _compute_show_oneline_package_content_is_possible(self): + for menu in self: + menu.show_oneline_package_content_is_possible = menu.scenario_id.has_option( + "show_oneline_package_content" + ) + + @api.depends("scenario_id") + def _compute_scan_location_or_pack_first_is_possible(self): + for menu in self: + menu.scan_location_or_pack_first_is_possible = menu.scenario_id.has_option( + "scan_location_or_pack_first" + ) + + @api.depends("scenario_id") + def _compute_auto_post_line_is_possible(self): + for menu in self: + menu.auto_post_line_is_possible = menu.scenario_id.has_option( + "auto_post_line" + ) + + def _compute_allow_alternative_destination_is_possible(self): + for menu in self: + menu.allow_alternative_destination_is_possible = ( + menu.scenario_id.has_option("allow_alternative_destination") + ) + + @api.depends("scenario_id") + def _compute_allow_return_is_possible(self): + for menu in self: + menu.allow_return_is_possible = menu.scenario_id.has_option("allow_return") diff --git a/shopfloor/models/stock_location.py b/shopfloor/models/stock_location.py new file mode 100644 index 0000000000..fb344bab28 --- /dev/null +++ b/shopfloor/models/stock_location.py @@ -0,0 +1,76 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models +from odoo.tools.float_utils import float_compare + + +class StockLocation(models.Model): + _inherit = "stock.location" + + shopfloor_picking_sequence = fields.Char( + help="The picking done in Shopfloor scenarios will respect this order. " + "The sequence is a char so it can be composed of fields such as " + "'corridor-rack-side-level'. Pay attention to the padding " + "('09' is before '19', '9' is not). It is recommended to use an" + " Export then an Import to populate this field using a spreadsheet.", + ) + source_move_line_ids = fields.One2many( + comodel_name="stock.move.line", inverse_name="location_id", readonly=True + ) + reserved_move_line_ids = fields.One2many( + comodel_name="stock.move.line", + compute="_compute_reserved_move_lines", + ) + + def _get_reserved_move_lines(self): + return self.env["stock.move.line"].search( + [ + ("location_id", "child_of", self.id), + ("reserved_uom_qty", ">", 0), + ("state", "not in", ("done", "cancel")), + ] + ) + + def _compute_reserved_move_lines(self): + for rec in self: + rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()}) + + def planned_qty_in_location_is_empty(self, move_lines=None): + """Return if a location will be empty when move lines will be confirmed + + Used for the "zero check". We need to know if a location is empty, but since + we set the move lines to "done" only at the end of the unload workflow, we + have to look at the qty_done of the move lines from this location. + + With `move_lines` we can force the use of the given move lines for the check. + This allows to know that the location will be empty if we process only + these move lines. + """ + self.ensure_one() + quants = self.env["stock.quant"].search( + [("quantity", ">", 0), ("location_id", "=", self.id)] + ) + remaining = sum(quants.mapped("quantity")) + move_line_qty_field = "qty_done" + if move_lines: + move_lines = move_lines.filtered( + lambda m: m.state not in ("cancel", "done") + ) + move_line_qty_field = "reserved_uom_qty" + else: + move_lines = self.env["stock.move.line"].search( + [ + ("state", "not in", ("cancel", "done")), + ("location_id", "=", self.id), + ("qty_done", ">", 0), + ] + ) + planned = remaining - sum(move_lines.mapped(move_line_qty_field)) + compare = float_compare(planned, 0, precision_rounding=0.01) + return compare <= 0 + + def should_bypass_reservation(self): + self.ensure_one() + if self.env.context.get("force_reservation"): + return False + return super().should_bypass_reservation() diff --git a/shopfloor/models/stock_move.py b/shopfloor/models/stock_move.py new file mode 100644 index 0000000000..4dfdd22dfb --- /dev/null +++ b/shopfloor/models/stock_move.py @@ -0,0 +1,119 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2022 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, models +from odoo.tools.float_utils import float_compare + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _qty_is_satisfied(self): + compare = float_compare( + self.quantity_done, + self.product_uom_qty, + precision_rounding=self.product_uom.rounding, + ) + # greater or equal + return compare in (0, 1) + + def split_other_move_lines(self, move_lines, intersection=False): + """Substract `move_lines` from `move.move_line_ids`, put the result + in a new move and returns it. + + If `intersection` is set to `True`, this is the common lines between + `move_lines` and `move.move_line_ids` which will be put in a new move. + """ + self.ensure_one() + other_move_lines = self.move_line_ids - move_lines + if intersection: + to_move = self.move_line_ids & move_lines + else: + to_move = other_move_lines + if other_move_lines or self.state == "partially_available": + if intersection: + qty_to_split = sum(to_move.mapped("reserved_uom_qty")) + else: + qty_to_split = self.product_uom_qty - sum( + move_lines.mapped("reserved_uom_qty") + ) + split_move_vals = self._split(qty_to_split) + split_move = self.create(split_move_vals) + split_move.move_line_ids = to_move + split_move._action_confirm(merge=False) + split_move._recompute_state() + split_move._action_assign() + self._recompute_state() + return split_move + return self.browse() + + def split_unavailable_qty(self): + """Put unavailable qty of a partially available move in their own + move (which will be 'confirmed'). + """ + partial_moves = self.filtered(lambda m: m.state == "partially_available") + for partial_move in partial_moves: + partial_move.split_other_move_lines(partial_move.move_line_ids) + return partial_moves + + def _extract_in_split_order(self, default=None, backorder=False): + """Extract moves in a new picking + + :param default: dictionary of field values to override in the original + values of the copied record + :param backorder: indicate if the original picking can be seen as a + backorder after the split. You could apply a specific backorder + strategy (e.g. cancel it). + :return: the new order + """ + picking = self.picking_id + picking.ensure_one() + data = { + "name": "/", + "move_ids": [], + "move_line_ids": [], + "backorder_id": picking.id, + } + data.update(dict(default or [])) + new_picking = picking.copy(data) + link = '%s' % ( + new_picking.id, + new_picking.name, + ) + message = (_("The split order {} has been created.")).format(link) + picking.message_post(body=message) + self.picking_id = new_picking.id + self.package_level_id.picking_id = new_picking.id + self.move_line_ids.picking_id = new_picking.id + self.move_line_ids.package_level_id.picking_id = new_picking.id + self._action_assign() + return new_picking + + def extract_and_action_done(self): + """Extract the moves in a separate transfer and validate them. + + You can combine this method with `split_other_move_lines` method + to first extract some move lines in a separate move, then validate it + with this method. + """ + # Process assigned moves + moves = self.filtered(lambda m: m.state == "assigned") + if not moves: + return False + new_backorders = self.env["stock.picking"] + for picking in moves.picking_id: + existing_backorders = picking.backorder_ids + moves_todo = picking.move_ids & moves + # No need to create a new transfer if we are processing all moves + if moves_todo == picking.move_ids: + new_picking = picking + # We process some available moves of the picking, but there are still + # some other moves to process, then we put the moves to process in + # a new transfer to validate. All remaining moves stay in the + # current transfer. + else: + new_picking = moves_todo._extract_in_split_order() + assert new_picking.state == "assigned" + new_picking._action_done() + new_backorders |= new_picking.backorder_ids - existing_backorders + return new_backorders diff --git a/shopfloor/models/stock_move_line.py b/shopfloor/models/stock_move_line.py new file mode 100644 index 0000000000..05a470fc91 --- /dev/null +++ b/shopfloor/models/stock_move_line.py @@ -0,0 +1,307 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2022 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + +from odoo import _, exceptions, fields, models +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_compare, float_is_zero + +_logger = logging.getLogger(__name__) + + +class StockMoveLine(models.Model): + _name = "stock.move.line" + _inherit = ["stock.move.line", "shopfloor.priority.postpone.mixin"] + + # TODO use a serialized field + shopfloor_unloaded = fields.Boolean(default=False) + shopfloor_checkout_done = fields.Boolean(default=False) + shopfloor_user_id = fields.Many2one(comodel_name="res.users", index=True) + + date_planned = fields.Datetime(related="move_id.date", store=True, index=True) + + # we search lines based on their location in some workflows + location_id = fields.Many2one(index=True) + package_id = fields.Many2one(index=True) + + # allow domain on picking_id.xxx without too much perf penalty + picking_id = fields.Many2one(auto_join=True) + + def _split_partial_quantity(self): + """Create new move line for the quantity remaining to do + + :return: the new move line if created else empty recordset + """ + self.ensure_one() + rounding = self.product_uom_id.rounding + if float_is_zero(self.qty_done, precision_rounding=rounding): + return self.browse() + compare = float_compare( + self.qty_done, self.reserved_uom_qty, precision_rounding=rounding + ) + qty_lesser = compare == -1 + qty_greater = compare == 1 + assert not qty_greater, "Quantity done cannot exceed quantity to do" + if qty_lesser: + remaining = self.reserved_uom_qty - self.qty_done + new_line = self.copy({"reserved_uom_qty": remaining, "qty_done": 0}) + # if we didn't bypass reservation update, the quant reservation + # would be reduced as much as the deduced quantity, which is wrong + # as we only moved the quantity to a new move line + self.with_context( + bypass_reservation_update=True + ).reserved_uom_qty = self.qty_done + return new_line + return self.browse() + + def _extract_in_split_order(self, default=None): + """Have pickings fully reserved with only those move lines. + + If the condition is not met, extract the move lines in a new picking. + :param default: dictionary of field values to override in the original + values of the copied record + """ + for picking in self.picking_id: + moves_to_extract = new_move = picking.move_ids.browse() + need_backorder = need_split = False + for move in picking.move_ids: + if move.state in ("cancel", "done"): + continue + if move.state == "confirmed": + # The move has no ancestor and is not available + need_backorder = True + continue + move_lines = move.move_line_ids & self + if not move_lines: + # The picking contains moves not related to given move lines + need_split = True + continue + new_move = move.split_other_move_lines(move_lines, intersection=True) + if new_move: + if move.state == "confirmed": + # The move has no ancestor and is not available + need_backorder = True + else: + # The move contains other move lines + need_split = True + moves_to_extract += new_move or move + if need_split: + moves_to_extract._extract_in_split_order(default=default) + elif need_backorder: + # All the lines are processed but some moves are partially available + moves_to_extract._extract_in_split_order( + default=default, backorder=True + ) + + def _split_pickings_from_source_location(self): + """Ensure that the related pickings will have the same source location. + + Some pickings related could have other unrelated move lines, as such we + have to split them to contain only the move lines related to the expected + source location. + + Example: + + Initial data: + + PICK1: + - move line with source location LOC1 + - move line with source location LOC2 + PICK2: + - move line with source location LOC2 + - move line with source location LOC3 + + Then we process move lines related to LOC2 with this method, we get: + + PICK1: + - move line with source location LOC1 + PICK2: + - move line with source location LOC3 + PICK3: + - move line with source location LOC2 + - move line with source location LOC2 + + Return the pickings containing the given move lines. + """ + _logger.warning( + "`_split_pickings_from_source_location` is deprecated " + "and replaced by `_extract_in_split_order`" + ) + location_src_to_process = self.location_id + if location_src_to_process and len(location_src_to_process) != 1: + raise UserError( + _("Move lines processed have to share the same source location.") + ) + pickings = self.picking_id + move_lines_to_process_ids = [] + for picking in pickings: + location_src = picking.move_line_ids.location_id + if len(location_src) == 1: + continue + (picking.move_line_ids & self)._extract_in_split_order() + # Get the related move lines among the picking and split them + move_lines_to_process_ids.extend( + set(picking.move_line_ids.ids) & set(self.ids) + ) + return self.picking_id + + def _split_qty_to_be_done(self, qty_done, split_partial=True, **split_default_vals): + """Check qty to be done for current move line. Split it if needed. + + :param qty_done: qty expected to be done + :param split_partial: split if qty is less than expected + otherwise rely on a backorder. + """ + # store a new line if we have split our line (not enough qty) + new_line = self.env["stock.move.line"] + rounding = self.product_uom_id.rounding + compare = float_compare( + qty_done, self.reserved_uom_qty, precision_rounding=rounding + ) + qty_lesser = compare == -1 + qty_greater = compare == 1 + if qty_greater: + return (new_line, "greater") + elif qty_lesser: + if not split_partial: + return (new_line, "lesser") + new_line = self._split_partial_quantity_to_be_done( + qty_done, split_default_vals + ) + return (new_line, "lesser") + return (new_line, "full") + + def _split_partial_quantity_to_be_done(self, quantity_done, split_default_vals): + """Create a new move line with the remaining quantity to process.""" + # split the move line which will be processed later (maybe the user + # has to pick some goods from another place because the location + # contained less items than expected) + remaining = self.reserved_uom_qty - quantity_done + vals = {"reserved_uom_qty": remaining, "qty_done": 0} + vals.update(split_default_vals) + new_line = self.copy(vals) + # if we didn't bypass reservation update, the quant reservation + # would be reduced as much as the deduced quantity, which is wrong + # as we only moved the quantity to a new move line + self.with_context( + bypass_reservation_update=True + ).reserved_uom_qty = quantity_done + return new_line + + def replace_package(self, new_package): + """Replace a package on an assigned move line""" + self.ensure_one() + + # search other move lines which should already pick the scanned package + other_reserved_lines = self.env["stock.move.line"].search( + [ + ("package_id", "=", new_package.id), + ("state", "in", ("partially_available", "assigned")), + ] + ) + + # we can't change already picked lines + unreservable_lines = other_reserved_lines.filtered( + lambda line: line.qty_done == 0 + ) + to_assign_moves = unreservable_lines.move_id + + # if we leave the package level, it will try to reserve the same + # one again + unreservable_lines.package_level_id.explode_package() + # unreserve qties of other lines + unreservable_lines.unlink() + + if new_package.location_id != self.location_id: + if new_package.quant_ids.reserved_quantity: + # this is a unexpected condition: if we started picking a package + # in another location, user should never be able to scan it in + # another location, block the operation + raise exceptions.UserError( + _( + "Package {} has been partially picked in another location" + ).format(new_package.display_name) + ) + # the package has been scanned in the current location so we know its + # a mistake in the data... fix the quant to move the package here + new_package.move_package_to_location(self.location_id) + + # several move lines can be moved by the package level, if we change + # the package for the current one, we destroy the package level because + # we are no longer moving the entire package + self.package_level_id.explode_package() + + def is_greater(value, other, rounding): + return float_compare(value, other, precision_rounding=rounding) == 1 + + def is_lesser(value, other, rounding): + return float_compare(value, other, precision_rounding=rounding) == -1 + + quant = fields.first( + new_package.quant_ids.filtered( + lambda quant: quant.product_id == self.product_id + and is_greater( + quant.quantity, + quant.reserved_quantity, + quant.product_uom_id.rounding, + ) + ) + ) + if not quant: + raise exceptions.UserError( + _( + "Package %(package_name)s does not contain available product " + "%(product_name)s, cannot replace package.", + package_name=new_package.display_name, + product_name=self.product_id.display_name, + ) + ) + + values = { + "package_id": new_package.id, + "lot_id": quant.lot_id.id, + "owner_id": quant.owner_id.id, + "result_package_id": False, + } + + available_quantity = quant.quantity - quant.reserved_quantity + if is_lesser( + available_quantity, self.reserved_qty, quant.product_uom_id.rounding + ): + new_uom_qty = self.product_id.uom_id._compute_quantity( + available_quantity, self.product_uom_id, rounding_method="HALF-UP" + ) + values["reserved_uom_qty"] = new_uom_qty + + self.write(values) + + # try reassign the move in case we had a partial qty, also, it will + # recreate a package level if it applies + if "reserved_uom_qty" in values: + # when we change the quantity of the move, the state + # will still be "assigned" and be skipped by "_action_assign", + # recompute the state to be "partially_available" + self.move_id._recompute_state() + + # if the new package has less quantities, assign will create new move + # lines + self.move_id._action_confirm() + self.move_id._action_assign() + + # Find other available goods for the lines which were using the + # package before... + to_assign_moves._action_assign() + + # computation of the 'state' of the package levels is not + # triggered, force it + to_assign_moves.move_line_ids.package_level_id.modified(["move_line_ids"]) + self.package_level_id.modified(["move_line_ids"]) + + def _filter_on_picking(self, picking=False): + """Filter a bunch of lines on a picking. + + If no picking is provided the first one is taken. + """ + picking = picking or fields.first(self.picking_id) + return self.filtered_domain([("picking_id", "=", picking.id)]) diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py new file mode 100644 index 0000000000..04f5957d02 --- /dev/null +++ b/shopfloor/models/stock_package_level.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 import fields, models + + +class StockPackageLevel(models.Model): + _name = "stock.package_level" + _inherit = ["stock.package_level", "shopfloor.priority.postpone.mixin"] + + # we search package levels based on their package in some workflows + package_id = fields.Many2one(index=True) + # allow domain on picking_id.xxx without too much perf penalty + picking_id = fields.Many2one(auto_join=True) + + def explode_package(self): + """Unlink but keep the moves. + + Original motivation: + + A package level has a relation to "move_lines" only when the + package level was created first from the UI and it created + its move. + When we unlink a package level, it deletes the move it created. + But in some cases, we want to keep the move, e.g.: + + * create a package level from the UI to move a package + * it generates a move for the matching product quantity + * we use a barcode scenario such as cluster or zone picking + * we use the "replace package" button + * when replacing the package, we have to delete the package level, + but we still have the same need in term of "I want X products", + so we have to keep the move + * another case is when we "dismiss" the package level in the location + content transfer scenario, we want to keep the "need" in moves, but + we are no longer moving the entire package level + + Commit + + https://github.com/odoo/odoo/commit/b33e72d0bf027fb2c789b1b9476f7edf1a40b0a6 + + introduced the handling of pkg level deletion + which is doing what was done by this method. + + Moreover it has been fixed here https://github.com/odoo/odoo/pull/66517. + + Hence, we keep this method to unify the action of "exploding a package" + especially to avoid to refactor many places every time the core changes. + """ + # This will trigger the deletion of the pkg level + self.move_line_ids.result_package_id = False diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py new file mode 100644 index 0000000000..86ddb80c6e --- /dev/null +++ b/shopfloor/models/stock_picking.py @@ -0,0 +1,118 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, api, fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + total_weight = fields.Float( + compute="_compute_picking_info", + help="Technical field. Indicates total weight of transfers included.", + ) + move_line_count = fields.Integer( + compute="_compute_picking_info", + help="Technical field. Indicates number of move lines included.", + ) + package_level_count = fields.Integer( + compute="_compute_picking_info", + help="Technical field. Indicates number of package_level included.", + ) + bulk_line_count = fields.Integer( + compute="_compute_picking_info", + help="Technical field. Indicates number of move lines without package included.", + ) + + @api.depends( + "move_line_ids", "move_line_ids.reserved_qty", "move_line_ids.product_id.weight" + ) + def _compute_picking_info(self): + for item in self: + item.update( + { + "total_weight": item._calc_weight(), + "move_line_count": len(item.move_line_ids), + "package_level_count": len(item.package_level_ids), + # NOTE: not based on 'move_line_ids_without_package' field + # on purpose as it also takes into account the + # 'Move entire packs' option from the picking type. + "bulk_line_count": len( + item.move_line_ids.filtered(lambda ml: not ml.package_level_id) + ), + } + ) + + def _calc_weight(self): + weight = 0.0 + for move_line in self.mapped("move_line_ids"): + weight += move_line.reserved_qty * move_line.product_id.weight + return weight + + def _check_move_lines_map_quant_package(self, package): + # see tests/test_move_action_assign.py for details + pack_move_lines = self.move_line_ids.filtered( + lambda ml: ml.package_id == package + ) + # if we set a qty_done on any line, it's picked, we don't want + # to change it in any case, so we ignore the package level + if any(pack_move_lines.mapped("qty_done")): + return False + # if we already changed the destination package, do not create + # a new package level + if any( + line.result_package_id != package + for line in pack_move_lines + if line.result_package_id + ): + return False + return super()._check_move_lines_map_quant_package(package) + + def split_assigned_move_lines(self, move_lines=None): + """Put all reserved quantities (move lines) in their own moves and transfer. + + As a result, the current transfer will contain only confirmed moves. + """ + self.ensure_one() + # Check in the picking all the moves which are partially available or confirmed + moves = self.move_lines.filtered( + lambda m: m.state in ("partially_available", "confirmed") + ) + # If one of these moves has an ancestor, split the moves + # then extract all the assigned moves in a new transfer. + # Indeed, a move without ancestor won't see its reserved qty changed + # automatically over time. + has_ancestors = bool( + moves.move_orig_ids.filtered(lambda m: m.state not in ("cancel", "done")) + ) + if not has_ancestors: + return self.id + # Get only transfers composed of moves assigned or confirmed + moves.split_other_move_lines(moves.move_line_ids) + # Put assigned moves related to processed move lines into a separate transfer + if move_lines: + assigned_moves = self.move_lines & move_lines.move_id + else: + assigned_moves = self.move_lines.filtered(lambda m: m.state == "assigned") + if assigned_moves == self.move_lines: + return self.id + new_picking = self.copy( + { + "name": "/", + "move_lines": [], + "move_line_ids": [], + "backorder_id": self.id, + } + ) + message = _( + 'The backorder %(new_picking_name)s has been created.' + ) % dict(new_picking_id=new_picking.id, new_picking_name=new_picking.name) + self.message_post(body=message) + assigned_moves.write({"picking_id": new_picking.id}) + assigned_moves.mapped("move_line_ids").write({"picking_id": new_picking.id}) + assigned_moves.move_line_ids.package_level_id.write( + {"picking_id": new_picking.id} + ) + assigned_moves._action_assign() + return new_picking.id diff --git a/shopfloor/models/stock_picking_batch.py b/shopfloor/models/stock_picking_batch.py new file mode 100644 index 0000000000..f16aa11da1 --- /dev/null +++ b/shopfloor/models/stock_picking_batch.py @@ -0,0 +1,41 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models + + +class StockPickingBatch(models.Model): + _inherit = "stock.picking.batch" + + picking_count = fields.Integer( + compute="_compute_picking_info", + help="Technical field. Indicates number of transfers included.", + ) + move_line_count = fields.Integer( + compute="_compute_picking_info", + help="Technical field. Indicates number of move lines included.", + ) + total_weight = fields.Float( + compute="_compute_picking_info", + help="Technical field. Indicates total weight of transfers included.", + ) + + @api.depends( + "picking_ids.state", "picking_ids.total_weight", "picking_ids.move_line_ids" + ) + def _compute_picking_info(self): + for item in self: + assigned_pickings = item.picking_ids.filtered( + lambda picking: picking.state == "assigned" + ) + item.update( + { + "picking_count": len(assigned_pickings.ids), + "move_line_count": len( + assigned_pickings.mapped("move_line_ids").ids + ), + "total_weight": item._calc_weight(assigned_pickings), + } + ) + + def _calc_weight(self, pickings): + return sum(pickings.mapped("total_weight")) diff --git a/shopfloor/models/stock_picking_type.py b/shopfloor/models/stock_picking_type.py new file mode 100644 index 0000000000..83a636df73 --- /dev/null +++ b/shopfloor/models/stock_picking_type.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models + + +class StockPickingType(models.Model): + _inherit = "stock.picking.type" + + shopfloor_menu_ids = fields.Many2many( + comodel_name="shopfloor.menu", + string="Shopfloor Menus", + readonly=True, + ) + shopfloor_zero_check = fields.Boolean( + string="Activate Zero Check", + help="For Shopfloor scenarios using it (Cluster Picking, Zone Picking," + " Discrete order Picking), the zero check step will be activated when" + " a location becomes empty after a move.", + ) + + @api.constrains("show_entire_packs") + def _check_move_entire_packages(self): + menu_items = self.env["shopfloor.menu"].search( + [("picking_type_ids", "in", self.ids)] + ) + menu_items._check_move_entire_packages() diff --git a/shopfloor/models/stock_quant.py b/shopfloor/models/stock_quant.py new file mode 100644 index 0000000000..79c1b3d8d3 --- /dev/null +++ b/shopfloor/models/stock_quant.py @@ -0,0 +1,31 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import models + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + def user_has_groups(self, groups): + if self.env.context.get("_sf_inventory"): + allow_groups = groups.split(",") + # action_validate checks if the user is a manager, but + # in shopfloor, we want to programmatically create and + # validate inventories under the hood. sudo sets the su + # flag but not the group: allow to bypass the check when + # sudo is used. + if "stock.group_stock_manager" in allow_groups and self.env.su: + return True + return super().user_has_groups(groups) + + def _is_inventory_mode(self): + """Used to control whether a quant was written on or created during an + "inventory session", meaning a mode where we need to create the stock.move + record necessary to be consistent with the `inventory_quantity` field. + """ + # The default method check if we have the stock.group_stock_manager + # group, however, we want to force using this mode from shopfloor + # (cluster picking) when sudo is used and the user is a stock user. + if self.env.context.get("inventory_mode") is True and self.env.su: + return True + return super()._is_inventory_mode() diff --git a/shopfloor/models/stock_quant_package.py b/shopfloor/models/stock_quant_package.py new file mode 100644 index 0000000000..d00159c57d --- /dev/null +++ b/shopfloor/models/stock_quant_package.py @@ -0,0 +1,101 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, api, exceptions, fields, models + + +class StockQuantPackage(models.Model): + _inherit = "stock.quant.package" + + move_line_ids = fields.One2many( + comodel_name="stock.move.line", + inverse_name="package_id", + readonly=True, + help="Technical field. Move lines moving this package.", + ) + planned_move_line_ids = fields.One2many( + comodel_name="stock.move.line", + inverse_name="result_package_id", + domain=[("state", "not in", ("done", "cancel"))], + readonly=True, + help="Technical field. Move lines for which destination is this package.", + ) + # TODO: review other fields + reserved_move_line_ids = fields.One2many( + comodel_name="stock.move.line", + compute="_compute_reserved_move_lines", + ) + shopfloor_weight = fields.Float( + "Shopfloor weight (kg)", + digits="Product Unit of Measure", + compute="_compute_shopfloor_weight", + help="Real pack weight or the estimated one.", + ) + + def _get_reserved_move_lines(self): + return self.env["stock.move.line"].search( + [("package_id", "=", self.id), ("state", "not in", ("done", "cancel"))] + ) + + @api.depends("move_line_ids.state") + def _compute_reserved_move_lines(self): + for rec in self: + rec.update({"reserved_move_line_ids": rec._get_reserved_move_lines()}) + + @api.depends("pack_weight", "estimated_pack_weight_kg") + @api.depends_context("picking_id") + def _compute_shopfloor_weight(self): + for rec in self: + rec.shopfloor_weight = rec.pack_weight or rec.estimated_pack_weight_kg + + # TODO: we should refactor this like + + # source_planned_move_line_ids + # destination_planned_move_line_ids + + # filter out done/cancel lines + + @api.constrains("name") + def _constrain_name_unique(self): + for rec in self: + if self.search_count([("name", "=", rec.name), ("id", "!=", rec.id)]): + raise exceptions.UserError(_("Package name must be unique!")) + + def move_package_to_location(self, dest_location): + """Create inventories to move a package to a different location + + It should be called when the package is - in real life - already in + the destination. It creates an inventory to remove the package from + the source location and a second inventory to place the package + in the destination (to reflect the reality). + + The source location is the current location of the package. + """ + quant_values = [] + # sudo and the key in context activate is_inventory_mode on quants + quants = self.quant_ids.sudo().with_context( + inventory_mode=True, _sf_inventory=True + ) + for quant in quants: + quantity = quant.quantity + quant.inventory_quantity_auto_apply = 0 + quant_values.append( + self._move_package_quant_move_values(quant, dest_location, quantity) + ) + + quant_model = ( + self.env["stock.quant"] + .sudo() + .with_context(inventory_mode=True, _sf_inventory=True) + ) + quant_model.create(quant_values) + return + + def _move_package_quant_move_values(self, quant, location, quantity): + return { + "product_id": quant.product_id.id, + "inventory_quantity_auto_apply": quantity, + "location_id": location.id, + "lot_id": quant.lot_id.id, + "package_id": quant.package_id.id, + "owner_id": quant.owner_id.id, + } diff --git a/shopfloor/readme/CONTRIBUTORS.rst b/shopfloor/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..d715ff7c16 --- /dev/null +++ b/shopfloor/readme/CONTRIBUTORS.rst @@ -0,0 +1,18 @@ +* Guewen Baconnier +* Simone Orsi +* Sébastien Alix +* Alexandre Fayolle +* Benoit Guillot +* Thierry Ducrest +* Raphaël Reverdy +* Jacques-Etienne Baudoux +* Juan Miguel Sánchez Arce +* Michael Tietz (MT Software) +* Souheil Bejaoui +* Laurent Mignon + +Design +~~~~~~ + +* Joël Grand-Guillaume +* Jacques-Etienne Baudoux diff --git a/shopfloor/readme/CREDITS.rst b/shopfloor/readme/CREDITS.rst new file mode 100644 index 0000000000..dd789bcc35 --- /dev/null +++ b/shopfloor/readme/CREDITS.rst @@ -0,0 +1,5 @@ +**Financial support** + +* Cosanum +* Camptocamp R&D +* Akretion R&D diff --git a/shopfloor/readme/DESCRIPTION.rst b/shopfloor/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..cec69a4a45 --- /dev/null +++ b/shopfloor/readme/DESCRIPTION.rst @@ -0,0 +1,17 @@ +Shopfloor is a barcode scanner application for internal warehouse operations. + +The application supports scenarios, to relate to Operation Types: + +* Cluster Picking +* Zone Picking +* Checkout/Packing +* Delivery +* Location Content Transfer +* Single Pack Transfer + +This module provides REST APIs to support the scenarios. It needs a frontend +to consume the backend APIs and provide screens for users on barcode devices. +A default front-end application is provided by ``shopfloor_mobile``. + +| Note: if you want to enable a new scenario on an existing application, you must trigger the registry sync on the shopfloor.app in a post_init_hook or a post-migrate script. +| See an example `here `_. diff --git a/shopfloor/readme/HISTORY.rst b/shopfloor/readme/HISTORY.rst new file mode 100644 index 0000000000..dc27ee2605 --- /dev/null +++ b/shopfloor/readme/HISTORY.rst @@ -0,0 +1,4 @@ +13.0.1.0.0 +~~~~~~~~~~ + +First official version. diff --git a/shopfloor/readme/ROADMAP.rst b/shopfloor/readme/ROADMAP.rst new file mode 100644 index 0000000000..e4e135eaa9 --- /dev/null +++ b/shopfloor/readme/ROADMAP.rst @@ -0,0 +1,4 @@ +* improve documentation +* split out scenario components to their own modules +* maybe split common stock features to `shopfloor_stock_base` + and move scenario to `shopfloor_wms`? diff --git a/shopfloor/readme/USAGE.rst b/shopfloor/readme/USAGE.rst new file mode 100644 index 0000000000..9f4832dd09 --- /dev/null +++ b/shopfloor/readme/USAGE.rst @@ -0,0 +1,6 @@ +An API key is created in the Demo data (for development), using +the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DAC + +Curl example:: + + curl -X POST "http://localhost:8069/shopfloor/user/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" diff --git a/shopfloor/security/groups.xml b/shopfloor/security/groups.xml new file mode 100644 index 0000000000..bc177166d0 --- /dev/null +++ b/shopfloor/security/groups.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py new file mode 100644 index 0000000000..c299b657a6 --- /dev/null +++ b/shopfloor/services/__init__.py @@ -0,0 +1,16 @@ +# core classes +from . import service + +# generic services +from . import menu + +# process services +from . import checkout +from . import zone_picking +from . import cluster_picking +from . import delivery +from . import location_content_transfer +from . import single_pack_transfer + +# forms +from . import forms diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py new file mode 100644 index 0000000000..a0da9ae89b --- /dev/null +++ b/shopfloor/services/checkout.py @@ -0,0 +1,1763 @@ +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from werkzeug.exceptions import BadRequest + +from odoo import _, fields + +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + +from ..utils import to_float + + +class Checkout(Component): + """ + Methods for the Checkout Process + + This scenario runs on existing moves. + It happens on the "Packing" step of a pick/pack/ship. + + Use cases: + + 1) Products are packed (e.g. full pallet shipping) and we keep the packages + 2) Products are packed (e.g. rollercage bins) and we create a new package + with same content for shipping + 3) Products are packed (e.g. half-pallet ) and we merge several into one + 4) Products are packed (e.g. too high pallet) and we split it on several + 5) Products are not packed (e.g. raw products) and we create new packages + 6) Products are not packed (e.g. raw products) and we do not create packages + + A new flag ``shopfloor_checkout_done`` on move lines allows to track which + lines have been checked out (can be with or without package). + + Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.checkout" + _usage = "checkout" + _description = __doc__ + + def _response_for_select_line( + self, picking, message=None, need_confirm_pack_all=False + ): + if all(line.shopfloor_checkout_done for line in picking.move_line_ids): + return self._response_for_summary(picking, message=message) + return self._response( + next_state="select_line", + data=self._data_for_select_line( + picking, need_confirm_pack_all=need_confirm_pack_all + ), + message=message, + ) + + def _data_for_select_line(self, picking, need_confirm_pack_all=False): + return { + "picking": self._data_for_stock_picking(picking), + "group_lines_by_location": True, + "show_oneline_package_content": self.work.menu.show_oneline_package_content, + "need_confirm_pack_all": need_confirm_pack_all, + } + + def _response_for_summary(self, picking, need_confirm=False, message=None): + return self._response( + next_state="summary" if not need_confirm else "confirm_done", + data={ + "picking": self._data_for_stock_picking(picking, done=True), + "all_processed": not bool(self._lines_to_pack(picking)), + }, + message=message, + ) + + def _response_for_select_document(self, message=None): + return self._response(next_state="select_document", message=message) + + def _response_for_manual_selection(self, message=None): + pickings = self.env["stock.picking"].search( + self._domain_for_list_stock_picking(), + order=self._order_for_list_stock_picking(), + ) + data = {"pickings": self.data.pickings(pickings)} + return self._response(next_state="manual_selection", data=data, message=message) + + def _response_for_select_package(self, picking, lines, message=None): + return self._response( + next_state="select_package", + data={ + "selected_move_lines": self._data_for_move_lines(lines.sorted()), + "picking": self.data.picking(picking), + "packing_info": self._data_for_packing_info(picking), + "no_package_enabled": not self.options.get( + "checkout__disable_no_package" + ), + }, + message=message, + ) + + 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 _response_for_select_dest_package(self, picking, move_lines, message=None): + 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, + move_lines, + 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, + ) + 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_packaging(self, picking, packaging, message=None): + return self._response( + next_state="select_delivery_packaging", + data={ + # We don't need to send the 'picking' as the mobile frontend + # already has this info after `select_document` state + # TODO adapt other endpoints to see if we can get rid of the + # 'picking' data + "packaging": self._data_for_delivery_packaging(packaging), + }, + message=message, + ) + + def _response_for_change_packaging(self, picking, package, packaging_list): + if not package: + return self._response_for_summary( + picking, message=self.msg_store.record_not_found() + ) + + return self._response( + next_state="change_packaging", + data={ + "picking": self.data.picking(picking), + "package": self.data.package( + package, picking=picking, with_packaging=True + ), + "packaging": self.data.delivery_packaging_list( + packaging_list.sorted("sequence") + ), + }, + ) + + def scan_document(self, barcode): + """Scan a package, a product, a transfer or a location + + When a location is scanned, if all the move lines from this destination + are for the same stock.picking, the stock.picking is used for the + next steps. + + When a package is scanned, if the package has a move line to move it + from a location/sublocation of the current stock.picking.type, the + stock.picking for the package is used for the next steps. + + When a product is scanned, use the first picking (ordered by priority desc, + scheduled_date asc, id desc) which has an ongoing move line with no source + package for the given product. + + When a stock.picking is scanned, it is used for the next steps. + + In every case above, the stock.picking must be entirely available and + must match the current picking type. + + Transitions: + * select_document: when no stock.picking could be found + * select_line: a stock.picking is selected + * summary: stock.picking is selected and all its lines have a + destination pack set + """ + search_result = self._scan_document_find(barcode) + result_handler = getattr(self, "_select_document_from_" + search_result.type) + return result_handler(search_result.record) + + def _scan_document_find(self, barcode, search_types=None): + search = self._actions_for("search") + search_types = ( + "picking", + "location", + "package", + "product", + "packaging", + ) + return search.find( + barcode, + types=search_types, + ) + + def _select_document_from_picking(self, picking, **kw): + return self._select_picking(picking, "select_document") + + def _select_document_from_location(self, location, **kw): + if not self.is_src_location_valid(location): + return self._response_for_select_document( + message=self.msg_store.location_not_allowed() + ) + lines = location.source_move_line_ids + pickings = lines.mapped("picking_id") + if len(pickings) > 1: + return self._response_for_select_document( + message={ + "message_type": "error", + "body": _( + "Several transfers found, please scan a package" + " or select a transfer manually." + ), + } + ) + return self._select_picking(pickings, "select_document") + + def _select_document_from_package(self, package, **kw): + pickings = package.move_line_ids.filtered( + lambda ml: ml.state not in ("cancel", "done") + ).mapped("picking_id") + if len(pickings) > 1: + # Filter only if we find several pickings to narrow the + # selection to one of the good type. If we have one picking + # of the wrong type, it will be caught in _select_picking + # with the proper error message. + # Side note: rather unlikely to have several transfers ready + # and moving the same things + pickings = pickings.filtered( + lambda p: p.picking_type_id in self.picking_types + ) + if len(pickings) == 1: + picking = pickings + return self._select_picking(picking, "select_document") + + def _select_document_from_product(self, product, line_domain=None, **kw): + line_domain = line_domain or [] + line_domain.extend( + [ + ("product_id", "=", product.id), + ("state", "not in", ("cancel", "done")), + ("package_id", "=", False), + ] + ) + lines = self.env["stock.move.line"].search(line_domain) + picking = self.env["stock.picking"].search( + [ + ("id", "in", lines.move_id.picking_id.ids), + ("picking_type_id", "in", self.picking_types.ids), + ], + order="priority desc, scheduled_date asc, id desc", + limit=1, + ) + return self._select_picking(picking, "select_document") + + def _select_document_from_packaging(self, packaging, **kw): + # And retrieve its product + product = packaging.product_id + # The picking should have a move line for the product + # where qty >= packaging.qty, since it doesn't makes sense + # to select a move line which have less qty than the packaging + line_domain = [("reserved_uom_qty", ">=", packaging.qty)] + return self._select_document_from_product(product, line_domain=line_domain) + + def _select_document_from_none(self, picking, **kw): + """Handle result when no record is found.""" + return self._select_picking(picking, "select_document") + + def _select_picking(self, picking, state_for_error): + if not picking: + if state_for_error == "manual_selection": + return self._response_for_manual_selection( + message=self.msg_store.stock_picking_not_found() + ) + return self._response_for_select_document( + message=self.msg_store.barcode_not_found() + ) + if picking.picking_type_id not in self.picking_types: + if state_for_error == "manual_selection": + return self._response_for_manual_selection( + message=self.msg_store.cannot_move_something_in_picking_type() + ) + return self._response_for_select_document( + message=self.msg_store.cannot_move_something_in_picking_type() + ) + if picking.state != "assigned": + if state_for_error == "manual_selection": + return self._response_for_manual_selection( + message=self.msg_store.stock_picking_not_available(picking) + ) + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_available(picking) + ) + return self._response_for_select_line(picking) + + def _data_for_move_lines(self, lines, **kw): + return self.data.move_lines(lines, **kw) + + def _data_for_delivery_packaging(self, packaging, **kw): + return self.data.delivery_packaging_list(packaging, **kw) + + def _data_for_stock_picking(self, picking, done=False): + data = self.data.picking(picking) + line_picker = self._lines_checkout_done if done else self._lines_to_pack + data.update( + { + "move_lines": self._data_for_move_lines( + self._lines_prepare(picking, line_picker(picking)), + with_packaging=done, + ) + } + ) + return data + + def _lines_checkout_done(self, picking): + return picking.move_line_ids.filtered(self._filter_lines_checkout_done) + + def _lines_to_pack(self, picking): + return picking.move_line_ids.filtered(self._filter_lines_unpacked) + + def _lines_prepare(self, picking, selected_lines): + """Hook to manipulate lines' ordering or anything else before sending them back.""" + return selected_lines + + def _domain_for_list_stock_picking(self): + return [ + ("state", "=", "assigned"), + ("picking_type_id", "in", self.picking_types.ids), + ] + + def _order_for_list_stock_picking(self): + return "scheduled_date asc, id asc" + + def list_stock_picking(self): + """List stock.picking records available + + Returns a list of all the available records for the current picking + type. + + Transitions: + * manual_selection: to the selection screen + """ + return self._response_for_manual_selection() + + def select(self, picking_id): + """Select a stock picking for the scenario + + Used from the list of stock pickings (manual_selection), from there, + the user can click on a stock.picking record which calls this method. + + The ``list_stock_picking`` returns only the valid records (same picking + type, fully available, ...), but this method has to check again in case + something changed since the list was sent to the client. + + Transitions: + * manual_selection: stock.picking could finally not be selected (not + available, ...) + * summary: goes straight to this state used to set the moves as done when + all the move lines with a reserved quantity have a 'quantity done' + * select_line: the "normal" case, when the user has to put in pack/move + lines + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_manual_selection(message=message) + return self._select_picking(picking, "manual_selection") + + def _select_lines(self, lines, prefill_qty=0, related_lines=None): + for i, line in enumerate(lines): + if line.shopfloor_checkout_done: + continue + if self.work.menu.no_prefill_qty and i == 0: + # For prefill quantity we only want to increment one line + line.qty_done += prefill_qty + elif not self.work.menu.no_prefill_qty: + line.qty_done = line.reserved_uom_qty + line.shopfloor_user_id = self.env.user + + picking = lines.mapped("picking_id") + other_lines = picking.move_line_ids - lines + self._deselect_lines(other_lines) + if related_lines: + lines += related_lines + return lines + + def _deselect_lines(self, lines): + lines.filtered(lambda l: not l.shopfloor_checkout_done).write( + {"qty_done": 0, "shopfloor_user_id": False} + ) + + def scan_line(self, picking_id, barcode, confirm_pack_all=False): + """Scan move lines of the stock picking + + It allows to select move lines of the stock picking for the next + screen. Lines can be found either by scanning a package, a product or a + lot. + + There should be no ambiguity, so for instance if a product is scanned but + several packs contain it, the endpoint will ask to scan a pack; if the + product is tracked by lot, to scan a lot. + + Once move lines are found, their ``qty_done`` is set to their reserved + quantity. + + Transitions: + * select_line: nothing could be found for the barcode + * select_package: lines are selected, user is redirected to this + * summary: delivery package is scanned and all lines are done + screen to change the qty done and destination pack if needed + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + + selection_lines = self._lines_to_pack(picking) + if not selection_lines: + return self._response_for_summary(picking) + + search_result = self._scan_line_find(picking, barcode) + result_handler = getattr(self, "_select_lines_from_" + search_result.type) + kw = {"confirm_pack_all": confirm_pack_all} + return result_handler(picking, selection_lines, search_result.record, **kw) + + def _scan_line_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 _select_lines_from_none(self, picking, selection_lines, record, **kw): + """Handle result when no record is found.""" + return self._response_for_select_line( + picking, message=self.msg_store.barcode_not_found() + ) + + def _select_lines_from_package(self, picking, selection_lines, package, **kw): + lines = selection_lines.filtered( + lambda l: l.package_id == package and not l.shopfloor_checkout_done + ) + if not lines: + return self._response_for_select_line( + picking, + message={ + "message_type": "error", + "body": _("Package {} is not in the current transfer.").format( + package.name + ), + }, + ) + self._select_lines(lines) + if self.work.menu.no_prefill_qty: + lines = picking.move_line_ids + return self._response_for_select_package(picking, lines) + + def _select_lines_from_product( + self, picking, selection_lines, product, prefill_qty=1, **kw + ): + if product.tracking in ("lot", "serial"): + return self._response_for_select_line( + picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot() + ) + + lines = selection_lines.filtered(lambda l: l.product_id == product) + if not lines: + return self._response_for_select_line( + picking, message=self.msg_store.product_not_found_in_current_picking() + ) + + # When products are as units outside of packages, we can select them for + # packing, but if they are in a package, we want the user to scan the packages. + # If the product is only in one package though, scanning the product selects + # the package. + packages = lines.mapped("package_id") + related_lines = self.env["stock.move.line"].browse() + # Do not use mapped here: we want to see if we have more than one package, + # but also if we have one product as a package and the same product as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({line.package_id for line in lines}) > 1: + return self._response_for_select_line( + picking, message=self.msg_store.product_multiple_packages_scan_package() + ) + elif packages: + # Select all the lines of the package when we scan a product in a + # package and we have only one. + return self._select_lines_from_package(picking, selection_lines, packages) + else: + # There is no package on selected lines, so also select all other lines + # not in a package. But only the quantity on first selected lines + # are updated. + related_lines = selection_lines.filtered( + lambda l: not l.package_id and l.product_id != product + ) + + lines = self._select_lines( + lines, prefill_qty=prefill_qty, related_lines=related_lines + ) + return self._response_for_select_package(picking, lines) + + def _select_lines_from_packaging(self, picking, selection_lines, packaging, **kw): + return self._select_lines_from_product( + picking, selection_lines, packaging.product_id, prefill_qty=packaging.qty + ) + + def _select_lines_from_lot(self, picking, selection_lines, lot, **kw): + lines = selection_lines.filtered(lambda l: l.lot_id == lot) + if not lines: + return self._response_for_select_line( + picking, + message={ + "message_type": "error", + "body": _("Lot is not in the current transfer."), + }, + ) + + # When lots are as units outside of packages, we can select them for + # packing, but if they are in a package, we want the user to scan the packages. + # If the product is only in one package though, scanning the lot selects + # the package. + packages = lines.mapped("package_id") + # Do not use mapped here: we want to see if we have more than one + # package, but also if we have one lot as a package and the same lot as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({line.package_id for line in lines}) > 1: + return self._response_for_select_line( + picking, message=self.msg_store.lot_multiple_packages_scan_package() + ) + elif packages: + # Select all the lines of the package when we scan a lot in a + # package and we have only one. + return self._select_lines_from_package( + picking, selection_lines, packages, **kw + ) + + self._select_lines(lines, prefill_qty=1) + return self._response_for_select_package(picking, lines) + + def _select_lines_from_serial(self, picking, selection_lines, lot, **kw): + # Search for serial number is actually the same as searching for lot (as of v14...) + return self._select_lines_from_lot(picking, selection_lines, lot, **kw) + + def _select_lines_from_delivery_packaging( + self, picking, selection_lines, packaging, confirm_pack_all=False, **kw + ): + """Handle delivery packaging. + + + If a delivery pkg has been scanned: + + 1. validate it + 2. ask for confirmation to place all lines left into the same package + 3. if scanned twice for confirmation, + assign new package and skip `select_package` state + + """ + carrier = self._get_carrier(picking) + if carrier: + # Validate against carrier + is_valid = self._packaging_good_for_carrier(packaging, carrier) + else: + is_valid = True + if carrier and not is_valid: + return self._response_for_select_line( + picking, + message=self.msg_store.packaging_invalid_for_carrier( + packaging, carrier + ), + ) + if confirm_pack_all: + # Select all lines and pack them all w/o passing for select_package state + self._select_lines(selection_lines) + return self._create_and_assign_new_packaging( + picking, selection_lines, packaging=packaging + ) + return self._response_for_select_line( + picking, + message=self.msg_store.confirm_put_all_goods_in_delivery_package(packaging), + need_confirm_pack_all=True, + ) + + def _select_line_package(self, picking, selection_lines, package): + if not package: + return self._response_for_select_line( + picking, message=self.msg_store.record_not_found() + ) + return self._select_lines_from_package(picking, selection_lines, package) + + def _select_line_move_line(self, picking, selection_lines, move_line): + if not move_line: + return self._response_for_select_line( + picking, message=self.msg_store.record_not_found() + ) + # normally, the client should sent only move lines out of packages, but + # in case there is a package, handle it as a package + if move_line.package_id: + return self._select_lines_from_package( + picking, selection_lines, move_line.package_id + ) + self._select_lines(move_line) + return self._response_for_select_package(picking, move_line) + + def select_line(self, picking_id, package_id=None, move_line_id=None): + """Select move lines of the stock picking + + This is the same as ``scan_line``, except that a package id or a + move_line_id is given by the client (user clicked on a list). + + It returns a list of move line ids that will be displayed by the + screen ``select_package``. This screen will have to send this list to + the endpoints it calls, so we can select/deselect lines but still + show them in the list of the client application. + + Transitions: + * select_line: nothing could be found for the barcode + * select_package: lines are selected, user is redirected to this + screen to change the qty done and destination package if needed + """ + assert package_id or move_line_id + + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + + selection_lines = self._lines_to_pack(picking) + if not selection_lines: + return self._response_for_summary(picking) + + if package_id: + package = self.env["stock.quant.package"].browse(package_id).exists() + return self._select_line_package(picking, selection_lines, package) + if move_line_id: + move_line = self.env["stock.move.line"].browse(move_line_id).exists() + return self._select_line_move_line(picking, selection_lines, move_line) + + def _change_line_qty( + self, picking_id, selected_line_ids, move_line_ids, quantity_func + ): + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + + move_lines = self.env["stock.move.line"].browse(move_line_ids).exists() + + message = None + if not move_lines: + message = self.msg_store.record_not_found() + for move_line in move_lines: + qty_done = quantity_func(move_line) + if qty_done < 0: + message = { + "body": _("Negative quantity not allowed."), + "message_type": "error", + } + else: + new_line = self.env["stock.move.line"] + if qty_done > 0: + new_line, qty_check = move_line._split_qty_to_be_done( + qty_done, + split_partial=False, + result_package_id=False, + ) + move_line.qty_done = qty_done + if new_line: + selected_line_ids.append(new_line.id) + if qty_done > move_line.reserved_uom_qty: + return self._response_for_select_package( + picking, + self.env["stock.move.line"].browse(selected_line_ids).exists(), + message=self.msg_store.line_scanned_qty_done_higher_than_allowed(), + ) + return self._response_for_select_package( + picking, + self.env["stock.move.line"].browse(selected_line_ids).exists(), + message=message, + ) + + def reset_line_qty(self, picking_id, selected_line_ids, move_line_id): + """Reset qty_done of a move line to zero + + Used to deselect a line in the "select_package" screen. + The selected_line_ids parameter is used to keep the selection of lines + stateless. + + Transitions: + * select_package: goes back to the same state, the line will appear + as deselected + """ + return self._change_line_qty( + picking_id, selected_line_ids, [move_line_id], lambda __: 0 + ) + + def set_line_qty(self, picking_id, selected_line_ids, move_line_id): + """Set qty_done of a move line to its reserved quantity + + Used to select a line in the "select_package" screen. + The selected_line_ids parameter is used to keep the selection of lines + stateless. + + Transitions: + * select_package: goes back to the same state, the line will appear + as selected + """ + return self._change_line_qty( + picking_id, selected_line_ids, [move_line_id], lambda l: l.reserved_uom_qty + ) + + def set_custom_qty(self, picking_id, selected_line_ids, move_line_id, qty_done): + """Change qty_done of a move line with a custom value + + The selected_line_ids parameter is used to keep the selection of lines + stateless. + + Transitions: + * select_package: goes back to this screen showing all the lines after + we changed the qty + """ + return self._change_line_qty( + picking_id, selected_line_ids, [move_line_id], lambda __: qty_done + ) + + def _switch_line_qty_done(self, picking, selected_lines, switch_lines): + """Switch qty_done on lines and return to the 'select_package' state + + If at least one of the lines to switch has a qty_done, set them all + to zero. If all the lines to switch have a zero qty_done, switch them + to their quantity to deliver. + """ + if any(line.qty_done for line in switch_lines): + return self._change_line_qty( + picking.id, selected_lines.ids, switch_lines.ids, lambda __: 0 + ) + else: + return self._change_line_qty( + picking.id, + selected_lines.ids, + switch_lines.ids, + lambda l: l.reserved_uom_qty, + ) + + def _increment_custom_qty( + self, picking, selected_lines, increment_lines, qty_increment + ): + """Increment the qty_done of a move line with a custom value + + The selected_line parameter is used to keep the selection of lines + stateless. + + Transitions: + * select_package: goes back to this screen showing all the lines after + we changed the qty + """ + return self._change_line_qty( + picking.id, + selected_lines.ids, + increment_lines.ids, + lambda line: line.qty_done + qty_increment, + ) + + @staticmethod + def _filter_lines_unpacked(move_line): + return ( + move_line.qty_done == 0 or move_line.shopfloor_user_id + ) and not move_line.shopfloor_checkout_done + + @staticmethod + def _filter_lines_to_pack(move_line): + return move_line.qty_done > 0 and not move_line.shopfloor_checkout_done + + @staticmethod + def _filter_lines_checkout_done(move_line): + return move_line.qty_done > 0 and move_line.shopfloor_checkout_done + + def _is_package_allowed(self, picking, package): + """Check if a package is allowed as a destination/delivery package. + + A package is allowed as a destination one if it is present among + `picking` lines and qualified as a "delivery package" (having a + delivery packaging set on it). + """ + existing_packages = picking.mapped("move_line_ids.result_package_id").filtered( + "package_type_id" + ) + return package in existing_packages + + def _put_lines_in_package(self, picking, selected_lines, package): + """Put the current selected lines with a qty_done in a package + + Note: only packages which are already a delivery package for another + line of the stock picking can be selected. Packages which are the + source packages are allowed too only if it is a delivery package (we + keep the current package). + """ + if not self._is_package_allowed(picking, package): + return self._response_for_select_package( + picking, + selected_lines, + message=self.msg_store.dest_package_not_valid(package), + ) + return self._pack_lines(picking, selected_lines, package) + + def _put_lines_in_allowed_package(self, picking, lines_to_pack, package): + for line in lines_to_pack: + if line.qty_done < line.reserved_uom_qty: + line._split_partial_quantity_to_be_done(line.qty_done, {}) + lines_to_pack.write( + {"result_package_id": package.id, "shopfloor_checkout_done": True} + ) + self._post_put_lines_in_package(lines_to_pack) + # Hook to this method to override the response + # if anything else has to be handled + # before auto posting the lines. + return {} + + def _post_put_lines_in_package(self, lines_packaged): + """Hook to override.""" + + def _create_and_assign_new_packaging(self, picking, selected_lines, packaging=None): + actions = self._actions_for("packaging") + package = actions.create_package_from_packaging(packaging=packaging) + return self._pack_lines(picking, selected_lines, package) + + def _pack_lines(self, picking, selected_lines, package): + lines_to_pack = selected_lines.filtered(self._filter_lines_to_pack) + if not lines_to_pack: + message = self.msg_store.no_line_to_pack() + return self._response_for_select_line( + picking, + message=message, + ) + response = self._put_lines_in_allowed_package(picking, lines_to_pack, package) + if response: + return response + if self.work.menu.auto_post_line: + # If option auto_post_line is active in the shopfloor menu, + # create a split order with these packed lines. + self._auto_post_lines(lines_to_pack) + message = self.msg_store.goods_packed_in(package) + # go back to the screen to select the next lines to pack + return self._response_for_select_line( + picking, + message=message, + ) + + def scan_package_action(self, picking_id, selected_line_ids, barcode): + """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 packaging type (one without related product) 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 + """ + 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 = self._scan_package_find(picking, barcode) + result_handler = getattr( + self, "_scan_package_action_from_" + search_result.type + ) + return result_handler(picking, selected_lines, search_result.record) + + 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 _scan_package_action_from_product( + self, picking, selected_lines, product, packaging=None, **kw + ): + if product.tracking in ("lot", "serial"): + return self._response_for_select_package( + picking, + selected_lines, + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + product_lines = selected_lines.filtered(lambda l: l.product_id == product) + if self.work.menu.no_prefill_qty: + quantity_increment = packaging.qty if packaging else 1 + return self._increment_custom_qty( + picking, + selected_lines, + fields.first(product_lines), + quantity_increment, + ) + return self._switch_line_qty_done(picking, selected_lines, product_lines) + + def _scan_package_action_from_packaging( + self, picking, selected_lines, packaging, **kw + ): + return self._scan_package_action_from_product( + picking, selected_lines, packaging.product_id, packaging=packaging + ) + + def _scan_package_action_from_lot(self, picking, selected_lines, lot, **kw): + lot_lines = selected_lines.filtered(lambda l: l.lot_id == lot) + if self.work.menu.no_prefill_qty: + return self._increment_custom_qty( + picking, selected_lines, fields.first(lot_lines), 1 + ) + return self._switch_line_qty_done(picking, selected_lines, lot_lines) + + def _scan_package_action_from_serial(self, picking, selection_lines, lot, **kw): + # Search for serial number is actually the same as searching for lot (as of v14...) + return self._scan_package_action_from_lot(picking, selection_lines, lot, **kw) + + def _scan_package_action_from_package(self, picking, selected_lines, package, **kw): + if not package.package_type_id: + return self._response_for_select_package( + picking, + selected_lines, + message=self.msg_store.dest_package_not_valid(package), + ) + return self._put_lines_in_package(picking, selected_lines, package) + + def _scan_package_action_from_delivery_packaging( + self, picking, selected_lines, packaging, **kw + ): + carrier = self._get_carrier(picking) + if carrier: + # Validate against carrier + is_valid = self._packaging_type_good_for_carrier(packaging, carrier) + else: + is_valid = True + if carrier and not is_valid: + return self._response_for_select_package( + picking, + selected_lines, + message=self.msg_store.packaging_invalid_for_carrier( + packaging, carrier + ), + ) + return self._create_and_assign_new_packaging(picking, selected_lines, packaging) + + def _scan_package_action_from_none(self, picking, selected_lines, record, **kw): + return self._response_for_select_package( + picking, selected_lines, message=self.msg_store.barcode_not_found() + ) + + def _get_carrier(self, picking): + return picking.ship_carrier_id or picking.carrier_id + + def _packaging_type_good_for_carrier(self, packaging, carrier): + actions = self._actions_for("packaging") + return actions.packaging_type_valid_for_carrier(packaging, carrier) + + def _packaging_good_for_carrier(self, packaging, carrier): + actions = self._actions_for("packaging") + return actions.packaging_valid_for_carrier(packaging, carrier) + + def _get_available_delivery_packaging(self, picking): + model = self.env["stock.package.type"] + carrier = picking.ship_carrier_id or picking.carrier_id + if not carrier: + return model.browse() + return model.search( + [("package_carrier_type", "=", carrier.delivery_type or "none")], + order="name", + ) + + def list_delivery_packaging(self, picking_id, selected_line_ids): + """List available delivery packaging for given picking. + + Transitions: + * select_delivery_packaging: list available delivery packaging, the + user has to choose one to create the new package + * select_package: when no delivery packaging is available + """ + 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() + delivery_packaging = self._get_available_delivery_packaging(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_packaging(picking, delivery_packaging) + + def new_package(self, picking_id, selected_line_ids, package_type_id=None): + """Add all selected lines in a new package + + It creates a new package and set it as the destination package of all + the selected lines. + + Selected lines are move lines in the list of ``move_line_ids`` where + ``qty_done`` > 0 and have no destination package + (shopfloor_checkout_done is False). + + Transitions: + * select_line: goes back to selection of lines to work on next lines + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + packaging = None + if package_type_id: + packaging = self.env["stock.package.type"].browse(package_type_id).exists() + selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + return self._create_and_assign_new_packaging(picking, selected_lines, packaging) + + def no_package(self, picking_id, selected_line_ids): + """Process all selected lines without any package. + + Selected lines are move lines in the list of ``move_line_ids`` where + ``qty_done`` > 0 and have no destination package + (shopfloor_checkout_done is False). + + Transitions: + * select_line: goes back to selection of lines to work on next lines + """ + if self.options.get("checkout__disable_no_package"): + raise BadRequest("`checkout.no_package` endpoint is not enabled") + 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() + selected_lines.write( + {"shopfloor_checkout_done": True, "result_package_id": False} + ) + response = self._check_allowed_qty_done(picking, selected_lines) + if response: + return response + return self._response_for_select_line( + picking, + message={ + "message_type": "success", + "body": _("Product(s) processed as raw product(s)"), + }, + ) + + def list_dest_package(self, picking_id, selected_line_ids): + """Return a list of packages the user can select for the lines + + Only valid packages must be proposed. Look at ``scan_dest_package`` + for the conditions to be valid. + + Transitions: + * select_dest_package: selection screen + * select_package: when no package is available + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + response = self._check_allowed_qty_done(picking, lines) + if response: + return response + return self._response_for_select_dest_package(picking, lines) + + def _check_allowed_qty_done(self, picking, lines): + 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(), + ) + + def _set_dest_package_from_selection(self, picking, selected_lines, package): + if not self._is_package_allowed(picking, package): + return self._response_for_select_dest_package( + picking, + selected_lines, + message=self.msg_store.dest_package_not_valid(package), + ) + return self._pack_lines(picking, selected_lines, package) + + def scan_dest_package(self, picking_id, selected_line_ids, barcode): + """Scan destination package for lines + + Set the destination package on the selected lines with a `qty_done` if + the package is valid. It is valid when one of: + + * it is already the destination package of another line of the stock.picking + * it is the source package of the selected lines + + Note: by default, Odoo puts the same destination package as the source + package on lines. + + Transitions: + * select_dest_package: error when scanning package + * select_line: lines to package remain + * summary: all lines are put in packages + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + search = self._actions_for("search") + package = search.package_from_scan(barcode) + if not package: + return self._response_for_select_dest_package( + picking, + lines, + message=self.msg_store.package_not_found_for_barcode(barcode), + ) + return self._set_dest_package_from_selection(picking, lines, package) + + def set_dest_package(self, picking_id, selected_line_ids, package_id): + """Set destination package for lines from a package id + + Used by the list obtained from ``list_dest_package``. + + The validity is the same as ``scan_dest_package``. + + Transitions: + * select_dest_package: error when selecting package + * select_line: lines to package remain + * summary: all lines are put in packages + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + lines = self.env["stock.move.line"].browse(selected_line_ids).exists() + package = self.env["stock.quant.package"].browse(package_id).exists() + if not package: + return self._response_for_select_dest_package( + picking, + lines, + message=self.msg_store.record_not_found(), + ) + return self._set_dest_package_from_selection(picking, lines, package) + + def _auto_post_lines(self, selected_lines): + moves = self.env["stock.move"] + for line in selected_lines: + move = line.move_id.split_other_move_lines(line, intersection=True) + moves = moves | move + moves.extract_and_action_done() + + def summary(self, picking_id): + """Return information for the summary screen + + Transitions: + * summary + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + return self._response_for_summary(picking) + + def _get_allowed_packaging(self): + return self.env["stock.package.type"].search([]) + + def list_packaging(self, picking_id, package_id): + """List the available package types for a package + + For a package, we can change the packaging. The available + packaging are the ones with no product. + + Transitions: + * change_packaging + * summary: if the package_id no longer exists + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + package = self.env["stock.quant.package"].browse(package_id).exists() + packaging_list = self._get_allowed_packaging() + return self._response_for_change_packaging(picking, package, packaging_list) + + def set_packaging(self, picking_id, package_id, package_type_id): + """Set a package type on a package + + Transitions: + * change_packaging: in case of error + * summary + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + + package = self.env["stock.quant.package"].browse(package_id).exists() + packaging = self.env["stock.package.type"].browse(package_type_id).exists() + if not (package and packaging): + return self._response_for_summary( + picking, message=self.msg_store.record_not_found() + ) + package.package_type_id = packaging + return self._response_for_summary( + picking, + message={ + "message_type": "success", + "body": _("Packaging changed on package {}").format(package.name), + }, + ) + + def cancel_line(self, picking_id, package_id=None, line_id=None): + """Cancel work done on given line or package. + + If package, remove destination package from lines and set qty done to 0. + If line is a raw product, set qty done to 0. + + All the move lines with the package as ``result_package_id`` have their + ``result_package_id`` reset to the source package (default odoo behavior) + and their ``qty_done`` set to 0. + + It flags ``shopfloor_checkout_done`` to False + so they have to be processed again. + + Transitions: + * summary: if package or line are not found + * select_line: when package or line has been canceled + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + + package = self.env["stock.quant.package"].browse(package_id).exists() + line = self.env["stock.move.line"].browse(line_id).exists() + if not package and not line: + return self._response_for_summary( + picking, message=self.msg_store.record_not_found() + ) + + if package: + move_lines = picking.move_line_ids.filtered( + lambda l: self._filter_lines_checkout_done(l) + and l.result_package_id == package + ) + for move_line in move_lines: + move_line.write( + { + "qty_done": 0, + "result_package_id": move_line.package_id, + "shopfloor_checkout_done": False, + } + ) + msg = _("Package cancelled") + if line: + line.write({"qty_done": 0, "shopfloor_checkout_done": False}) + msg = _("Line cancelled") + return self._response_for_select_line( + picking, message={"message_type": "success", "body": msg} + ) + + def done(self, picking_id, confirmation=False): + """Set the moves as done + + If some lines have not the full ``qty_done`` or no destination package set, + a confirmation is asked to the user. + + Transitions: + * summary: in case of error + * select_document: after done, goes back to start + * confirm_done: confirm a partial + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_document(message=message) + lines = picking.move_line_ids + if not confirmation: + if not all(line.qty_done == line.reserved_uom_qty for line in lines): + return self._response_for_summary( + picking, + need_confirm=True, + message=self.msg_store.transfer_confirm_done(), + ) + elif not all(line.shopfloor_checkout_done for line in lines): + return self._response_for_summary( + picking, + need_confirm=True, + message={ + "message_type": "warning", + "body": _("Remaining raw product not packed, proceed anyway?"), + }, + ) + stock = self._actions_for("stock") + lines_done = self._lines_checkout_done(picking) + stock.validate_moves(lines_done.move_id) + return self._response_for_select_document( + message=self.msg_store.transfer_done_success(lines_done.picking_id) + ) + + +class ShopfloorCheckoutValidator(Component): + """Validators for the Checkout endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.checkout.validator" + _usage = "checkout.validator" + + def scan_document(self): + return {"barcode": {"required": True, "type": "string"}} + + def list_stock_picking(self): + return {} + + def select(self): + return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def scan_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "confirm_pack_all": { + "type": "boolean", + "nullable": True, + "required": False, + }, + } + + def select_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": False, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": False, "type": "integer"}, + } + + def reset_line_qty(self): + 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"}, + }, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def set_line_qty(self): + 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"}, + }, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def set_custom_qty(self): + 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"}, + }, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "qty_done": {"coerce": to_float, "required": True, "type": "float"}, + } + + def scan_package_action(self): + 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"}, + } + + def list_delivery_packaging(self): + 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"}, + }, + } + + def new_package(self): + 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"}, + }, + "package_type_id": { + "coerce": to_int, + "required": False, + "type": "integer", + }, + } + + def no_package(self): + return self.new_package() + + def list_dest_package(self): + 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"}, + }, + } + + def scan_dest_package(self): + 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"}, + } + + def set_dest_package(self): + 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"}, + }, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def summary(self): + return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def list_packaging(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def set_packaging(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_type_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + } + + def cancel_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": { + "coerce": to_int, + "required": False, + "type": "integer", + # excludes does not set the other as not required??? :/ + "excludes": "line_id", + }, + "line_id": { + "coerce": to_int, + "required": False, + "type": "integer", + "excludes": "package_id", + }, + } + + def done(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + +class ShopfloorCheckoutValidatorResponse(Component): + """Validators for the Checkout endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.checkout.validator.response" + _usage = "checkout.validator.response" + + _start_state = "select_document" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "select_document": {}, + "manual_selection": self._schema_selection_list, + "select_line": self._schema_stock_picking_details, + "select_package": dict( + self._schema_selected_lines, + packing_info={"type": "string", "nullable": True}, + no_package_enabled={ + "type": "boolean", + "nullable": True, + "required": False, + }, + ), + "change_quantity": self._schema_selected_lines, + "select_dest_package": self._schema_select_package, + "select_delivery_packaging": self._schema_select_delivery_packaging, + "summary": self._schema_summary, + "change_packaging": self._schema_select_packaging, + "confirm_done": self._schema_confirm_done, + } + + def _schema_stock_picking(self, lines_with_packaging=False): + schema = self.schemas.picking() + schema.update( + { + "move_lines": self.schemas._schema_list_of( + self.schemas.move_line(with_packaging=lines_with_packaging) + ) + } + ) + return {"picking": self.schemas._schema_dict_of(schema, required=True)} + + @property + def _schema_stock_picking_details(self): + return dict( + self._schema_stock_picking(), + group_lines_by_location={"type": "boolean"}, + show_oneline_package_content={"type": "boolean"}, + need_confirm_pack_all={"type": "boolean"}, + ) + + @property + def _schema_summary(self): + return dict( + self._schema_stock_picking(lines_with_packaging=True), + all_processed={"type": "boolean"}, + ) + + @property + def _schema_confirm_done(self): + return self._schema_stock_picking(lines_with_packaging=True) + + @property + def _schema_selection_list(self): + return { + "pickings": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.picking()}, + } + } + + @property + def _schema_select_package(self): + return { + "selected_move_lines": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "packages": { + "type": "list", + "schema": { + "type": "dict", + "schema": self.schemas.package(with_packaging=True), + }, + }, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } + + @property + def _schema_select_delivery_packaging(self): + return { + "packaging": self.schemas._schema_list_of( + self.schemas.delivery_packaging() + ), + } + + @property + def _schema_select_packaging(self): + return { + "picking": {"type": "dict", "schema": self.schemas.picking()}, + "package": { + "type": "dict", + "schema": self.schemas.package(with_packaging=True), + }, + "packaging": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.delivery_packaging()}, + }, + } + + @property + def _schema_selected_lines(self): + return { + "selected_move_lines": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } + + def scan_document(self): + return self._response_schema( + next_states={"select_document", "select_line", "summary"} + ) + + def list_stock_picking(self): + return self._response_schema(next_states={"manual_selection"}) + + def select(self): + return self._response_schema( + next_states={"manual_selection", "summary", "select_line"} + ) + + def scan_line(self): + return self._response_schema( + next_states={"select_line", "select_package", "summary"} + ) + + def select_line(self): + return self.scan_line() + + def reset_line_qty(self): + return self._response_schema(next_states={"select_package"}) + + def set_line_qty(self): + return self._response_schema(next_states={"select_package"}) + + def set_custom_qty(self): + return self._response_schema(next_states={"select_package"}) + + def scan_package_action(self): + return self._response_schema( + next_states={"select_package", "select_line", "summary"} + ) + + def list_delivery_packaging(self): + return self._response_schema( + next_states={"select_delivery_packaging", "select_package"} + ) + + def new_package(self): + return self._response_schema(next_states={"select_line", "summary"}) + + def no_package(self): + return self.new_package() + + def list_dest_package(self): + return self._response_schema( + next_states={"select_dest_package", "select_package"} + ) + + def scan_dest_package(self): + return self._response_schema( + next_states={ + "select_dest_package", + "select_package", + "select_line", + "summary", + } + ) + + def set_dest_package(self): + return self._response_schema( + next_states={ + "select_dest_package", + "select_package", + "select_line", + "summary", + } + ) + + def summary(self): + return self._response_schema(next_states={"summary"}) + + def list_packaging(self): + return self._response_schema(next_states={"change_packaging", "summary"}) + + def set_packaging(self): + return self._response_schema(next_states={"change_packaging", "summary"}) + + def cancel_line(self): + return self._response_schema(next_states={"summary", "select_line"}) + + def done(self): + return self._response_schema(next_states={"summary", "confirm_done"}) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py new file mode 100644 index 0000000000..59d1e38d47 --- /dev/null +++ b/shopfloor/services/cluster_picking.py @@ -0,0 +1,1628 @@ +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2022 Jacques-Etienne Baudoux (BCIM) +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, fields +from odoo.osv import expression + +from odoo.addons.base_rest.components.service import to_bool, to_int +from odoo.addons.component.core import Component + +from ..utils import to_float + + +class ClusterPicking(Component): + """ + Methods for the Cluster Picking Process + + The goal of this scenario is to do the pickings for a Picking Batch, for + several customers at once. + The process assumes that picking batch records already exist. + + At first, a user gets automatically a batch to work on (assigned to them), + or can select one from a list. + + The scenario has 2 main phases, which can be done one after the other or a + bit of both. The first one is picking goods and put them in a roller-cage. + + First phase, picking: + + * Pick a good (move line) from a source location, scan it to confirm it's + the expected one + * Scan the label of a Bin (package) in a roller-cage, put the good inside + (physically). Once the first move line of a picking has been scanned, the + screen will show the same destination package for all the other lines of + the picking to help the user grouping goods together, and will prevent + lines from other pickings to be put in the same destination package. + * If odoo thinks a source location is empty after picking the goods, a + "zero check" is done: it asks the user to confirm if it is empty or not + * Repeat until the end of the batch or the roller-cage is full (there is + button to declare this) + + Second phase, unload to destination: + + * If all the goods (move lines) in the roller-cage go to the same destination, + a screen asking a single barcode for the destination is shown + * Otherwise, the user has to scan one destination per Bin (destination + package of the moves). + * If all the goods are supposed to go to the same destination but user doesn't + want or can't, a "split" allows to reach the screen to scan one destination + per Bin. + * When everything has a destination set and the batch is not finished yet, + the user goes to the first phase of pickings again for the rest. + + Inside the main workflow, some actions are accessible from the client: + + * Change a lot or pack: if the expected lot is at the very bottom of the + location or a stock error forces a user to change lot or pack, user can + do it during the picking. + * Skip a line: during picking, for instance because a line is not accessible + easily, it can be postponed, note that skipped lines have to be done, they + are only moved to the end of the queue. + * Declare stock out: if a good is in fact not in stock or only partially. Note + the move lines will become unavailable or partially unavailable and will + generate a back-order. + * Full bin: declaring a full bin allows to move directly to the first phase + (picking) to the second one (unload). The scenario will go + back to the first phase if some lines remain in the queue of lines to pick. + + You will find a sequence diagram describing states and endpoints + relationships [here](../docs/cluster_picking_diag_seq.png). + Keep [the sequence diagram](../docs/cluster_picking_diag_seq.plantuml) + up-to-date if you change endpoints. + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.cluster.picking" + _usage = "cluster_picking" + _description = __doc__ + + def _response_for_start(self, message=None, popup=None): + return self._response(next_state="start", message=message, popup=popup) + + def _response_for_confirm_start(self, batch): + return self._response( + next_state="confirm_start", + data=self.data.picking_batch(batch, with_pickings=True), + ) + + def _response_for_manual_selection(self, batches, message=None): + data = { + "records": self.data.picking_batches(batches), + "size": len(batches), + } + return self._response(next_state="manual_selection", data=data, message=message) + + def _response_for_start_line( + self, move_line, message=None, popup=None, sublocation=None + ): + kw = {"sublocation": self.data.location(sublocation)} if sublocation else {} + data = self._data_move_line(move_line, **kw) + return self._response( + next_state="start_line", + data=data, + message=message, + popup=popup, + ) + + def _response_for_scan_destination(self, move_line, message=None, qty_done=None): + if qty_done is None: + data = self._data_move_line(move_line) + else: + data = self._data_move_line(move_line, qty_done=qty_done) + last_picked_line = self._last_picked_line(move_line.picking_id) + if last_picked_line: + # suggest pack to be used for the next line + data["package_dest"] = self.data.package( + last_picked_line.result_package_id.with_context( + picking_id=move_line.picking_id.id + ), + picking=move_line.picking_id, + ) + data["disable_full_bin_action"] = self.work.menu.disable_full_bin_action + return self._response(next_state="scan_destination", data=data, message=message) + + def _response_for_change_pack_lot(self, move_line, message=None): + return self._response( + next_state="change_pack_lot", + data=self._data_move_line(move_line), + message=message, + ) + + def _response_for_zero_check(self, batch, move_line): + data = { + "id": move_line.id, + "location_src": self.data.location(move_line.location_id), + } + data["batch"] = self.data.picking_batch(batch) + return self._response(next_state="zero_check", data=data) + + def _response_for_unload_all(self, batch, message=None): + return self._response( + next_state="unload_all", + data=self._data_for_unload_all(batch), + message=message, + ) + + def _response_for_confirm_unload_all(self, batch, message=None): + return self._response( + next_state="confirm_unload_all", + data=self._data_for_unload_all(batch), + message=message, + ) + + def _response_for_unload_single(self, batch, package, message=None, popup=None): + return self._response( + next_state="unload_single", + data=self._data_for_unload_single(batch, package), + message=message, + popup=popup, + ) + + def _response_for_unload_set_destination(self, batch, package, message=None): + return self._response( + next_state="unload_set_destination", + data=self._data_for_unload_single(batch, package), + message=message, + ) + + def _response_for_confirm_unload_set_destination(self, batch, package): + return self._response( + next_state="confirm_unload_set_destination", + data=self._data_for_unload_single(batch, package), + ) + + def find_batch(self): + """Find a picking batch to work on and start it + + Usually the starting point of the scenario. + + Business rules to find a batch, try in order: + + a. Find a batch in progress assigned to the current user + b. Find a draft batch assigned to the current user: + 1. set it to 'in progress' + c. Find an unassigned draft batch: + 1. assign batch to the current user + 2. set it to 'in progress' + + Transitions: + * confirm_start: when it could find a batch + * start: when no batch is available + """ + batches = self._batch_picking_search() + selected = self._select_a_picking_batch(batches) + if selected: + return self._response_for_confirm_start(selected) + else: + return self._response_for_start( + message={ + "message_type": "info", + "body": _("No more work to do, please create a new batch transfer"), + }, + ) + + def list_batch(self): + """List picking batch on which user can work + + Returns a list of all the available records for the current picking + type. + + Transitions: + * manual_selection: to the selection screen + """ + batches = self._batch_picking_search() + return self._response_for_manual_selection(batches) + + def _batch_picking_base_search_domain(self): + return [ + "|", + "&", + ("user_id", "=", False), + ("state", "=", "draft"), + "&", + ("user_id", "=", self.env.user.id), + ("state", "in", ("draft", "in_progress")), + ] + + def _batch_picking_search(self, name_fragment=None, batch_ids=None): + domain = self._batch_picking_base_search_domain() + if name_fragment: + domain = expression.AND([domain, [("name", "ilike", name_fragment)]]) + if batch_ids: + domain = expression.AND([domain, [("id", "in", batch_ids)]]) + records = self.env["stock.picking.batch"].search(domain, order="id asc") + records = records.filtered(self._batch_filter) + return records + + def _batch_filter(self, batch): + if not batch.picking_ids: + return False + return batch.picking_ids.filtered(self._batch_picking_filter) + + def _batch_picking_filter(self, picking): + # Picking type guard + if picking.picking_type_id not in self.picking_types: + return False + # Include done/cancel because we want to be able to work on the + # batch even if some pickings are done/canceled. They'll should be + # ignored later. + # When the batch is already in progress, we do not care + # about state of the pickings, because we want to be able + # to recover it in any case, even if, for instance, a stock + # error changed a picking to unavailable after the user + # started to work on the batch. + return picking.batch_id.state == "in_progress" or picking.state in ( + "assigned", + "done", + "cancel", + ) + + def _select_a_picking_batch(self, batches): + # look for in progress + assigned to self first + candidates = batches.filtered( + lambda batch: batch.state == "in_progress" + and batch.user_id == self.env.user + ) + if candidates: + return candidates[0] + # then look for draft assigned to self + candidates = batches.filtered(lambda batch: batch.user_id == self.env.user) + if candidates: + batch = candidates[0] + batch.write({"state": "in_progress"}) + return batch + # finally take any batch that search could return + if batches: + batch = batches[0] + batch.write({"user_id": self.env.uid, "state": "in_progress"}) + return batch + return self.env["stock.picking.batch"] + + def select(self, picking_batch_id): + """Manually select a picking batch + + The client application can use the service /picking_batch/search + to get the list of candidate batches. Then, it starts to work on + the selected batch by calling this. + + Note: it should be able to work only on batches which are in draft or + (in progress and assigned to the current user), the search method that + lists batches filter them, but it has to be checked again here in case + of race condition. + + Transitions: + * manual_selection: a selected batch cannot be used (assigned to someone else + concurrently for instance) + * confirm_start: after the batch has been assigned to the user + """ + batches = self._batch_picking_search(batch_ids=[picking_batch_id]) + selected = self._select_a_picking_batch(batches) + if selected: + return self._response_for_confirm_start(selected) + else: + return self._response( + base_response=self.list_batch(), + message={ + "message_type": "warning", + "body": _("This batch cannot be selected."), + }, + ) + + def confirm_start(self, picking_batch_id): + """User confirms they start a batch + + Should have no effect in odoo besides logging and routing the user to + the next action. The next action is "start_line" with data about the + line to pick. + + Transitions: + * start_line: when the batch has at least one line without destination + package + * start: if the condition above is wrong (rare case of race condition...) + """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + return self._pick_next_line(batch) + + def _pick_next_line(self, batch, message=None, force_line=None): + if force_line: + next_line = force_line + else: + next_line = self._next_line_for_pick(batch) + if not next_line: + return self.prepare_unload(batch.id) + return self._response_for_start_line(next_line, message=message) + + @staticmethod + def _sort_key_lines(line): + return ( + line.shopfloor_priority or 10, + line.location_id.shopfloor_picking_sequence or "", + line.location_id.name, + -int(line.move_id.priority or 0), + line.move_id.date, + line.move_id.sequence, + line.move_id.id, + line.id, + ) + + def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): + lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func) + # TODO test line sorting and all these methods to retrieve lines + + # Sort line by source location, + # so that the picker start w/ products in the same location. + # Postponed lines must come always + # after ALL the other lines in the batch are processed. + return lines.sorted(key=self._sort_key_lines) + + def _lines_to_pick(self, picking_batch): + return self._lines_for_picking_batch( + picking_batch, + filter_func=lambda l: ( + l.state in ("assigned", "partially_available") + # On 'StockPicking.action_assign()', result_package_id is set to + # the same package as 'package_id'. Here, we need to exclude lines + # that were already put into a bin, i.e. the destination package + # is different. + and (not l.result_package_id or l.result_package_id == l.package_id) + ), + ) + + def _last_picked_line(self, picking): + """Get the last line picked and put in a pack for this picking""" + return fields.first( + picking.move_line_ids.filtered( + lambda l: l.qty_done > 0 + and l.result_package_id + # 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 _next_line_for_pick(self, picking_batch): + remaining_lines = self._lines_to_pick(picking_batch) + return fields.first(remaining_lines) + + def _response_batch_does_not_exist(self): + return self._response_for_start(message=self.msg_store.record_not_found()) + + def _data_move_line(self, line, **kw): + picking = line.picking_id + batch = picking.batch_id + product = line.product_id + data = self.data.move_line(line) + # additional values + # Ensure destination pack is never proposed on the frontend. + # This should happen only as proposal on `scan_destination` + # where we set the last used package. + data["package_dest"] = None + data["batch"] = self.data.picking_batch(batch) + data["picking"] = self.data.picking(picking) + data["postponed"] = line.shopfloor_postponed + data["product"]["qty_available"] = product.with_context( + location=line.location_id.id + ).qty_available + data["scan_location_or_pack_first"] = self.work.menu.scan_location_or_pack_first + data.update(kw) + return data + + def unassign(self, picking_batch_id): + """Unassign and reset to draft a started picking batch + + Transitions: + * "start" to work on a new batch + """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if batch.exists(): + batch.write({"state": "draft", "user_id": False}) + return self._response_for_start() + + def scan_line(self, picking_batch_id, move_line_id, barcode, sublocation_id=None): + """Scan a location, a pack, a product or a lots + + There is no side-effect, it is only to check that the operator takes + the expected pack or product. + + User can scan a location if there is only pack inside. Otherwise, they + have to precise what they want by scanning one of: + + * pack + * product + * lot + + The result must be unambigous. For instance if we scan a product but the + product is tracked by lot, scanning the lot has to be required. + + `sublocation_id` is used when the scan_location_or_pack_first option is + switched on and the location contains multiple products with no lot or package. + The user will first scan the location and then the product, the backend needs + to know a location has been scanned previously. + + Transitions: + * start_line: with an appropriate message when user has + to scan for the same line again + * start_line: with the next line if the line was added to a + pack meanwhile (race condition). + * scan_destination: if the barcode matches. + """ + sublocation = ( + self.env["stock.location"].browse(sublocation_id).exists() + if sublocation_id + else self.env["stock.location"] + ) + 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() + ) + + search = self._actions_for("search") + + picking = move_line.picking_id + + package = search.package_from_scan(barcode) + if package and move_line.package_id == package: + return self._scan_line_by_package( + picking, move_line, package, batch, sublocation + ) + + product = search.product_from_scan(barcode) + if product and move_line.product_id == product: + return self._scan_line_by_product(picking, move_line, product, sublocation) + + packaging = search.packaging_from_scan(barcode) + if move_line.product_id == packaging.product_id: + return self._scan_line_by_packaging( + picking, move_line, packaging, sublocation + ) + + lot = search.lot_from_scan(barcode, products=move_line.product_id) + if lot and move_line.lot_id == lot: + return self._scan_line_by_lot(picking, move_line, lot, sublocation) + + location = search.location_from_scan(barcode) + if location and move_line.location_id == location: + return self._scan_line_by_location(picking, move_line, location) + + # Nothing matches what is expected from the move line. + for rec in (package, product, lot, location): + if rec: + return self._response_for_start_line( + move_line, message=self.msg_store.wrong_record(rec) + ) + return self._response_for_start_line( + move_line, message=self.msg_store.barcode_not_found() + ) + + def _get_prefill_qty(self, move_line, qty=0): + """Returns the quantity to increment depending on no_prefill_qty optione.""" + if self.work.menu.no_prefill_qty: + return qty + return move_line.reserved_uom_qty + + def _check_first_scan_location_or_pack_first( + self, move_line, sublocation=None, location_scanned=False + ): + """Restrict scanning product or lot first with option on. + + When the option first scan location or pack first is on. + When the line being worked on has a package, asked to scan the package first. + When the line as a lot ask to scan the location first. + """ + if not self.work.menu.scan_location_or_pack_first: + return None + message = None + if move_line.package_id: + message = self.msg_store.line_has_package_scan_package() + elif not location_scanned and not sublocation: + message = self.msg_store.scan_the_location_first() + if message: + return self._response_for_start_line( + move_line, + message=message, + sublocation=location_scanned or sublocation or None, + ) + return None + + def _scan_line_by_package(self, picking, move_line, package, batch, sublocation): + """Package scanned, just work with it.""" + quantity = self._get_prefill_qty(move_line) + return self._response_for_scan_destination(move_line, qty_done=quantity) + + def _scan_line_by_product(self, picking, move_line, product, sublocation): + """Product scanned, check if we can work with it. + + If scanned product is part of several packages in the same location, + we can't be sure it's the correct one, in such case, ask to scan a package. + + If the product is tracked by lot and there is only one lot id in the location + not in a package. It can safely be picked up. + """ + message = None + location_quants = move_line.location_id.quant_ids.filtered( + lambda quant: quant.quantity > 0 and quant.product_id == product + ) + packages = location_quants.mapped("package_id") + + response = self._check_first_scan_location_or_pack_first(move_line, sublocation) + if response: + return response + + if move_line.product_id.tracking == "lot": + lots_at_location = location_quants.mapped("lot_id") + if len(lots_at_location) > 1 or packages: + message = self.msg_store.scan_lot_on_product_tracked_by_lot() + elif move_line.product_id.tracking == "serial": + message = self.msg_store.scan_lot_on_product_tracked_by_lot() + if message: + return self._response_for_start_line(move_line, message=message) + + # Do not use mapped here: we want to see if we have more than one package, + # but also if we have one product as a package and the same product as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({quant.package_id for quant in location_quants}) > 1: + return self._response_for_start_line( + move_line, + message=self.msg_store.product_multiple_packages_scan_package(), + ) + quantity = self._get_prefill_qty(move_line, qty=1) + return self._response_for_scan_destination(move_line, qty_done=quantity) + + def _scan_line_by_packaging(self, picking, move_line, packaging, sublocation): + """Packaging scanned, check if we can work with it. + + If the packaging related product is part of several packages in the same location, + we can't be sure it's the correct one, in such case, ask to scan a package + """ + response = self._check_first_scan_location_or_pack_first(move_line, sublocation) + if response: + return response + + product = packaging.product_id + if move_line.product_id.tracking in ("lot", "serial"): + return self._response_for_start_line( + move_line, message=self.msg_store.scan_lot_on_product_tracked_by_lot() + ) + other_product_lines = picking.move_line_ids.filtered( + lambda l: l.product_id == product and l.location_id == move_line.location_id + ) + packages = other_product_lines.mapped("package_id") + # Do not use mapped here: we want to see if we have more than one package, + # but also if we have one product as a package and the same product as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({line.package_id for line in other_product_lines}) > 1: + return self._response_for_start_line( + move_line, + message=self.msg_store.product_multiple_packages_scan_package(), + ) + quantity = self._get_prefill_qty(move_line, packaging.qty) + return self._response_for_scan_destination(move_line, qty_done=quantity) + + def _scan_line_by_lot(self, picking, move_line, lot, sublocation): + """Lot scanned, check if we can work with it. + + If we scanned a lot and it's part of several packages, we can't be + sure the user scanned the correct one, in such case, ask to scan a package + """ + response = self._check_first_scan_location_or_pack_first(move_line, sublocation) + if response: + return response + + location_quants = move_line.location_id.quant_ids.filtered( + lambda quant: quant.quantity > 0 and quant.lot_id == lot + ) + packages = location_quants.package_id + + # Do not use mapped here: we want to see if we have more than one + # package, but also if we have one lot as a package and the same lot as + # a unit in another quant. In both cases, we want the user to scan the + # package. + if packages and len({quant.package_id for quant in location_quants}) > 1: + return self._response_for_start_line( + move_line, message=self.msg_store.lot_multiple_packages_scan_package() + ) + quantity = self._get_prefill_qty(move_line, 1.0) + return self._response_for_scan_destination(move_line, qty_done=quantity) + + def _scan_line_by_location(self, picking, move_line, location): + """Location scanned, check if we can work on goods contained into it. + + When a user scan a location, we accept only when we knows that + they scanned the good thing, so if in the location we have + several lots (on a package or a product), several packages, + several products or a mix of several products and packages, we + ask to scan a more precise barcode. + """ + response = self._check_first_scan_location_or_pack_first( + move_line, None, location_scanned=location + ) + if response: + return response + + location_quants = move_line.location_id.quant_ids.filtered( + lambda quant: quant.quantity > 0 + ) + lots = location_quants.lot_id + if len(lots) > 1: + return self._response_for_start_line( + move_line, + message=self.msg_store.several_lots_in_location(move_line.location_id), + sublocation=location, + ) + packages = location_quants.package_id + products = location_quants.product_id + if len(packages) > 1 or len(products) > 1: + if move_line.package_id: + return self._response_for_start_line( + move_line, + message=self.msg_store.several_packs_in_location( + move_line.location_id, + ), + sublocation=location, + ) + else: + return self._response_for_start_line( + move_line, + message=self.msg_store.several_products_in_location( + move_line.location_id, + ), + sublocation=location, + ) + quantity = self._get_prefill_qty(move_line) + return self._response_for_scan_destination(move_line, qty_done=quantity) + + def _set_destination_pack_update_quantity(self, move_line, quantity, barcode): + """Handle the done quantity increment on set_destination end point.""" + response = None + if not self.work.menu.no_prefill_qty: + return response + search = self._actions_for("search") + # Handle barcode of product or packaging + product = search.product_from_scan(barcode) + packaging = self.env["product.packaging"].browse() + if not product: + packaging = search.packaging_from_scan(barcode) + product = packaging.product_id + if product and move_line.product_id == product: + quantity += packaging.qty or 1.0 + response = self._response_for_scan_destination(move_line, qty_done=quantity) + return response + # Handle barcode of a lot + lot = search.lot_from_scan(barcode) + if lot and move_line.lot_id == lot: + quantity += 1.0 + response = self._response_for_scan_destination(move_line, qty_done=quantity) + return response + return response + + def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantity): + """Scan the destination package (bin) for a move line + + If the quantity picked (passed to the endpoint) is < expected quantity, + it splits the move line. + It changes the destination package of the move line and set the "qty done". + It prevents to put a move line of a picking in a destination package + used for another picking. + + Transitions: + * zero_check: if the quantity of product moved is 0 in the + source location after the move (beware: at this point the product we put in + a bin is still considered to be in the source location, so we have to compute + the source location's quantity - qty_done). + * unload_all: when all lines have a destination package and they all + have the same destination. + * unload_single: when all lines have a destination package and they all + have the same destination. + * start_line: to pick the next line if any. + """ + 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() + ) + + response = self._set_destination_pack_update_quantity( + move_line, quantity, barcode + ) + if response: + return response + + new_line, qty_check = move_line._split_qty_to_be_done(quantity) + if qty_check == "greater": + return self._response_for_scan_destination( + move_line, + message=self.msg_store.unable_to_pick_more(move_line.reserved_uom_qty), + qty_done=quantity, + ) + + search = self._actions_for("search") + bin_package = search.package_from_scan(barcode) + if not bin_package: + return self._response_for_scan_destination( + move_line, + message=self.msg_store.bin_not_found_for_barcode(barcode), + qty_done=quantity, + ) + + # the scanned package can contain only move lines of the same picking + different_picking = any( + ml.picking_id != move_line.picking_id + for ml in bin_package.planned_move_line_ids.filtered( + lambda x: x.state not in ("done", "cancel") + ) + ) + multi_pick_allowed = self.work.menu.multiple_move_single_pack + if not multi_pick_allowed and (bin_package.quant_ids or different_picking): + return self._response_for_scan_destination( + move_line, + message={ + "message_type": "error", + "body": _( + "The destination bin {} is not empty, please take another." + ).format(bin_package.name), + }, + qty_done=quantity, + ) + move_line.write({"qty_done": quantity, "result_package_id": bin_package.id}) + + zero_check = move_line.picking_id.picking_type_id.shopfloor_zero_check + if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): + return self._response_for_zero_check(batch, move_line) + + return self._pick_next_line( + batch, + message=self.msg_store.x_units_put_in_package( + move_line.qty_done, move_line.product_id, move_line.result_package_id + ), + # if we split the move line, we want to process the one generated by the + # split right now + force_line=new_line, + ) + + def _are_all_dest_location_same(self, batch): + lines_to_unload = self._lines_to_unload(batch) + return len(lines_to_unload.mapped("location_dest_id")) == 1 + + def prepare_unload(self, picking_batch_id): + """Initiate the unloading phase of the scenario + + It goes to different screens depending if all the move lines have + the same destination or not. + + Transitions: + * unload_all: when all lines go to the same destination + * unload_single: when lines have different destinations + """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + if self._are_all_dest_location_same(batch): + return self._response_for_unload_all(batch) + else: + # the lines have different destinations + return self._unload_next_package(batch) + + def _data_for_unload_all(self, batch): + lines = self._lines_to_unload(batch) + # all the lines destinations are the same here, it looks + # only for the first one + first_line = fields.first(lines) + data = self.data.picking_batch(batch) + data.update({"location_dest": self.data.location(first_line.location_dest_id)}) + return data + + def _data_for_unload_single(self, batch, package): + line = fields.first( + package.planned_move_line_ids.filtered(self._filter_for_unload) + ) + data = self.data.picking_batch(batch) + data.update( + { + "package": self.data.package(package), + "location_dest": self.data.location(line.location_dest_id), + } + ) + return data + + def _filter_for_unload(self, line): + return ( + line.state in ("assigned", "partially_available") + and line.qty_done > 0 + and line.result_package_id + and not line.shopfloor_unloaded + ) + + def _lines_to_unload(self, batch): + return self._lines_for_picking_batch(batch, filter_func=self._filter_for_unload) + + def _bin_packages_to_unload(self, batch): + lines = self._lines_to_unload(batch) + packages = lines.mapped("result_package_id").sorted() + return packages + + def _next_bin_package_for_unload_single(self, batch): + packages = self._bin_packages_to_unload(batch) + return fields.first(packages) + + def is_zero(self, picking_batch_id, move_line_id, zero): + """Confirm or not if the source location of a move has zero qty + + If the user confirms there is zero quantity, it means the stock was + correct and there is nothing to do. If the user says "no", a draft + empty inventory is created for the product (with lot if tracked). + + Transitions: + * start_line: if the batch has lines without destination package (bin) + * unload_all: if all lines have a destination package and same + destination + * unload_single: if all lines have a destination package and different + destination + """ + 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() + ) + + if not zero: + inventory = self._actions_for("inventory") + inventory.create_draft_check_empty( + move_line.location_id, + move_line.product_id, + ref=move_line.picking_id.name, + ) + + return self._pick_next_line( + batch, + message=self.msg_store.x_units_put_in_package( + move_line.qty_done, move_line.product_id, move_line.result_package_id + ), + ) + + def skip_line(self, picking_batch_id, move_line_id): + """Skip a line. The line will be processed at the end. + + It adds a flag on the move line, when the next line to pick + is searched, lines with such flag at moved to the end. + + A skipped line *must* be picked. + + Transitions: + * start_line: with data for the next line (or itself if it's the last one, + in such case, a helpful message is returned) + """ + 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() + ) + # flag as postponed + move_line.shopfloor_postpone(self._lines_to_pick(batch)) + return self._pick_after_skip_line(move_line) + + def _pick_after_skip_line(self, move_line): + batch = move_line.picking_id.batch_id + return self._pick_next_line(batch) + + def stock_issue(self, picking_batch_id, move_line_id): + """Declare a stock issue for a line + + After errors in the stock, the user cannot take all the products + because there is physically not enough goods. The move line is deleted + (unreserve), and an inventory is created to reduce the quantity in the + source location to prevent future errors until a correction. Beware: + the quantity already reserved by other lines should remain reserved so + the inventory's quantity must be set to the quantity of lines reserved + by other move lines (but not the current one). + + The other lines not yet picked in the batch for the same product, lot, + package are unreserved as well (moves lines deleted, which unreserve + their quantity on the move). + + A second inventory is created in draft to have someone do an inventory + check. + + Transitions: + * start_line: when the batch still contains lines without destination + package + * unload_all: if all lines have a destination package and same + destination + * unload_single: if all lines have a destination package and different + destination + * start: all lines are done/confirmed (because all lines were unloaded + and the last line has a stock issue). In this case, this method *has* + to handle the closing of the batch to create backorders (_unload_end) + """ + 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() + ) + + inventory = self._actions_for("inventory") + # create a draft inventory for a user to check + inventory.create_control_stock( + move_line.location_id, + move_line.product_id, + move_line.package_id, + move_line.lot_id, + ) + move = move_line.move_id + lot = move_line.lot_id + package = move_line.package_id + location = move_line.location_id + + # unreserve every lines for the same product/lot in the same batch and + # not done yet, so the same user doesn't have to declare 2 times the + # stock issue for the same thing! + domain = self._domain_stock_issue_unlink_lines(move_line) + unreserve_move_lines = move_line | self.env["stock.move.line"].search(domain) + unreserve_moves = unreserve_move_lines.mapped("move_id").sorted() + unreserve_move_lines.unlink() + + # Then, create an inventory with just enough qty so the other assigned + # move lines for the same product in other batches and the other move lines + # already picked stay assigned. + inventory.create_stock_issue(move, location, package, lot) + + # try to reassign the moves in case we have stock in another location + unreserve_moves._action_assign() + + return self._pick_next_line(batch) + + def _domain_stock_issue_unlink_lines(self, move_line): + # Since we have not enough stock, delete the move lines, which will + # in turn unreserve the moves. The moves lines we delete are those + # in the same batch (we don't want to interfere with other operators + # work, they'll have to declare a stock issue), and not yet started. + # The goal is to prevent the same operator to declare twice the same + # stock issue for the same product/lot/package. + batch = move_line.picking_id.batch_id + move = move_line.move_id + lot = move_line.lot_id + package = move_line.package_id + location = move_line.location_id + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", move.product_id.id), + ("package_id", "=", package.id), + ("lot_id", "=", lot.id), + ("state", "not in", ("cancel", "done")), + ("qty_done", "=", 0), + ("picking_id.batch_id", "=", batch.id), + ] + return domain + + def change_pack_lot(self, picking_batch_id, move_line_id, barcode, quantity=None): + """Change the expected pack or the lot for a line + + If the expected lot is at the very bottom of the location or a stock + error forces a user to change lot or pack, user can change the pack or + lot of the current line. + + The change occurs when the pack/product/lot is normally scanned and + goes directly to the scan of the destination package (bin) since we do + not need to check it. + + If the pack or lot was not supposed to be in the source location, + a draft inventory is created to have this checked. + + Transitions: + * scan_destination: the pack or the lot could be changed + * change_pack_lot: any error occurred during the change + """ + 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() + ) + search = self._actions_for("search") + response_ok_func = self._response_for_scan_destination + response_error_func = self._response_for_change_pack_lot + change_package_lot = self._actions_for("change.package.lot") + lot = search.lot_from_scan(barcode, products=move_line.product_id) + if lot: + response = change_package_lot.change_lot( + move_line, lot, response_ok_func, response_error_func + ) + if response: + if "scan_destination" in response["data"] and quantity is not None: + response["data"]["scan_destination"]["qty_done"] = quantity + return response + + package = search.package_from_scan(barcode) + if package: + response = change_package_lot.change_package( + move_line, package, response_ok_func, response_error_func + ) + if "scan_destination" in response["data"] and quantity is not None: + response["data"]["scan_destination"]["qty_done"] = quantity + return response + + return self._response_for_change_pack_lot( + move_line, + message=self.msg_store.no_package_or_lot_for_barcode(barcode), + ) + + def set_destination_all(self, picking_batch_id, barcode, confirmation=False): + """Set the destination for all the lines of the batch with a dest. package + + This method must be used only if all the move lines which have a destination + package and qty done have the same destination location. + + A scanned location outside of the source location of the operation type is + invalid. + + Transitions: + * start_line: the batch still have move lines without destination package + * unload_all: invalid destination, have to scan a good one + * confirm_unload_all: the scanned location is not the expected one (but + still a valid one) + * start: batch is totally done. In this case, this method *has* + to handle the closing of the batch to create backorders. + """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + + # In case /set_destination_all was called and the destinations were + # in fact no the same... restart the unloading step over + if not self._are_all_dest_location_same(batch): + return self.prepare_unload(batch.id) + + lines = self._lines_to_unload(batch) + if not lines: + return self._unload_end(batch) + + first_line = fields.first(lines) + scanned_location = self._actions_for("search").location_from_scan(barcode) + if not scanned_location: + return self._response_for_unload_all( + batch, message=self.msg_store.no_location_found() + ) + if not self.is_dest_location_valid(lines.move_id, scanned_location): + return self._response_for_unload_all( + batch, message=self.msg_store.dest_location_not_allowed() + ) + + if not confirmation and self.is_dest_location_to_confirm( + first_line.location_dest_id, scanned_location + ): + return self._response_for_confirm_unload_all(batch) + + self._unload_write_destination_on_lines(lines, scanned_location) + completion_info = self._actions_for("completion.info") + completion_info_popup = completion_info.popup(lines) + return self._unload_end(batch, completion_info_popup=completion_info_popup) + + def _unload_write_destination_on_lines(self, lines, location): + lines.write({"shopfloor_unloaded": True, "location_dest_id": location.id}) + lines.package_level_id.location_dest_id = location + for picking in lines.batch_id.picking_ids: + picking_lines = lines.filtered(lambda l, p=picking: l.picking_id == p) + self._unload_set_picking_to_done(picking, picking_lines) + + def _unload_set_picking_to_done(self, picking, picking_lines): + if picking.state == "done": + return + # We set the picking to done only when the last line is + # unloaded to avoid backorders. + all_lines_unloaded = all( + line.shopfloor_unloaded for line in picking.move_line_ids + ) + if self.work.menu.unload_package_at_destination and all_lines_unloaded: + picking_lines.result_package_id = False + if all_lines_unloaded: + picking._action_done() + + def _unload_end(self, batch, completion_info_popup=None): + """Try to close the batch if all transfers are done. + + Returns to `start_line` transition if some lines could still be processed, + otherwise try to validate all the transfers of the batch. + """ + all_pickings = batch.picking_ids + if all(picking.state == "done" for picking in all_pickings): + # do not use the 'done()' method because it does many things we + # don't care about + batch.state = "done" + return self._response_for_start( + message=self.msg_store.batch_transfer_complete(), + popup=completion_info_popup, + ) + + next_line = self._next_line_for_pick(batch) + if next_line: + return self._response_for_start_line( + next_line, + message=self.msg_store.batch_transfer_line_done(), + popup=completion_info_popup, + ) + else: + # TODO add tests for this (for instance a picking is not 'done' + # because a move was unassigned, we want to validate the batch to + # produce backorders) + all_pickings.filtered(lambda x: x.state == "assigned")._action_done() + batch.state = "done" + # Unassign not validated pickings from the batch, they will be + # processed in another batch automatically later on + all_pickings.invalidate_recordset(["state"]) + pickings_not_done = all_pickings.filtered(lambda p: p.state != "done") + pickings_not_done.batch_id = False + return self._response_for_start( + message=self.msg_store.batch_transfer_complete(), + popup=completion_info_popup, + ) + + def unload_split(self, picking_batch_id): + """Indicates that now the batch must be treated line per line + + Even if the move lines to unload all have the same destination. + + Note: if we go back to the first phase of picking and start a new + phase of unloading, the flag is reevaluated to the initial condition. + + Transitions: + * unload_single: always goes here since we now want to unload line per line + """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + + return self._unload_next_package(batch) + + def unload_scan_pack(self, picking_batch_id, package_id, barcode): + """Check that the operator scans the correct package (bin) on unload + + If the scanned barcode is not the one of the Bin (package), ask to scan + again. + + Transitions: + * unload_single: if the barcode does not match + * unload_set_destination: barcode is correct + """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + package = self.env["stock.quant.package"].browse(package_id) + if not package.exists(): + return self._unload_next_package(batch) + if package.name != barcode: + return self._response_for_unload_single( + batch, + package, + message={"message_type": "error", "body": _("Wrong bin")}, + ) + return self._response_for_unload_set_destination(batch, package) + + def unload_scan_destination( + self, picking_batch_id, package_id, barcode, confirmation=False + ): + """Scan the final destination for all the move lines moved with the Bin + + It updates all the assigned move lines with the package to the + destination. + + Transitions: + * unload_single: invalid scanned location or error + * unload_single: line is processed and the next bin can be unloaded + * confirm_unload_set_destination: the destination is valid but not the + expected, ask a confirmation. This state has to call again the + endpoint with confirmation=True + * start_line: if the batch still has lines to pick + * start: if the batch is done. In this case, this method *has* + to handle the closing of the batch to create backorders. + + """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + + package = self.env["stock.quant.package"].browse(package_id) + if not package.exists(): + return self._unload_next_package(batch) + + # we work only on the lines of the scanned package + lines = self._lines_to_unload(batch).filtered( + lambda l: l.result_package_id == package + ) + if not lines: + return self._unload_end(batch) + + return self._unload_scan_destination_lines( + batch, package, lines, barcode, confirmation=confirmation + ) + + def _lock_lines(self, lines): + """Lock move lines""" + self._actions_for("lock").for_update(lines) + + def _unload_scan_destination_lines( + self, batch, package, lines, barcode, confirmation=False + ): + # Lock move lines that will be updated + self._lock_lines(lines) + first_line = fields.first(lines) + scanned_location = self._actions_for("search").location_from_scan(barcode) + if not scanned_location: + return self._response_for_unload_set_destination( + batch, package, message=self.msg_store.no_location_found() + ) + if not self.is_dest_location_valid(lines.move_id, scanned_location): + return self._response_for_unload_set_destination( + batch, package, message=self.msg_store.dest_location_not_allowed() + ) + if not confirmation and self.is_dest_location_to_confirm( + first_line.location_dest_id, scanned_location + ): + return self._response_for_confirm_unload_set_destination(batch, package) + + self._unload_write_destination_on_lines(lines, scanned_location) + + completion_info = self._actions_for("completion.info") + completion_info_popup = completion_info.popup(lines) + + return self._unload_next_package( + batch, completion_info_popup=completion_info_popup + ) + + def _unload_next_package(self, batch, completion_info_popup=None): + next_package = self._next_bin_package_for_unload_single(batch) + if next_package: + return self._response_for_unload_single( + batch, next_package, popup=completion_info_popup + ) + return self._unload_end(batch, completion_info_popup=completion_info_popup) + + +class ShopfloorClusterPickingValidator(Component): + """Validators for the Cluster Picking endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.cluster_picking.validator" + _usage = "cluster_picking.validator" + + def find_batch(self): + return {} + + def list_batch(self): + return {} + + def select(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def confirm_start(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def unassign(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def scan_line(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "sublocation_id": {"required": False, "nullable": True, "type": "integer"}, + } + + def scan_destination_pack(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "quantity": { + "coerce": to_float, + "required": True, + "nullable": True, + "type": "float", + }, + } + + def prepare_unload(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def is_zero(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "zero": {"coerce": to_bool, "required": True, "type": "boolean"}, + } + + def skip_line(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def stock_issue(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def change_pack_lot(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "quantity": {"required": False, "type": "float"}, + } + + def set_destination_all(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + def unload_split(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def unload_scan_pack(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def unload_scan_destination(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + +class ShopfloorClusterPickingValidatorResponse(Component): + """Validators for the Cluster Picking endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.cluster_picking.validator.response" + _usage = "cluster_picking.validator.response" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "confirm_start": self._schema_for_batch_details, + "start_line": self._schema_for_single_line_details, + "start": {}, + "manual_selection": self._schema_for_batch_selection, + "scan_destination": self._schema_for_scan_destination, + "zero_check": self._schema_for_zero_check, + "unload_all": self._schema_for_unload_all, + "confirm_unload_all": self._schema_for_unload_all, + "unload_single": self._schema_for_unload_single, + "unload_set_destination": self._schema_for_unload_single, + "confirm_unload_set_destination": self._schema_for_unload_single, + "change_pack_lot": self._schema_for_single_line_details, + } + + def find_batch(self): + return self._response_schema(next_states={"confirm_start"}) + + def list_batch(self): + return self._response_schema(next_states={"manual_selection"}) + + def select(self): + return self._response_schema(next_states={"manual_selection", "confirm_start"}) + + def confirm_start(self): + return self._response_schema( + next_states={ + "start_line", + # we reopen a batch already started where all the lines were + # already picked and have to be unloaded to the same + # destination + "unload_all", + # we reopen a batch already started where all the lines were + # already picked and have to be unloaded to the different + # destinations + "unload_single", + } + ) + + def unassign(self): + return self._response_schema(next_states={"start"}) + + def scan_line(self): + return self._response_schema(next_states={"start_line", "scan_destination"}) + + def scan_destination_pack(self): + return self._response_schema( + next_states={ + # error during scan of pack (wrong barcode, ...) + "scan_destination", + # when we still have lines to process + "start_line", + # when the source location is empty + "zero_check", + # when all lines have been processed and have same + # destination + "unload_all", + # when all lines have been processed and have different + # destinations + "unload_single", + } + ) + + def prepare_unload(self): + return self._response_schema( + next_states={ + # when all lines have been processed and have same + # destination + "unload_all", + # when all lines have been processed and have different + # destinations + "unload_single", + } + ) + + def is_zero(self): + return self._response_schema( + next_states={ + # when we still have lines to process + "start_line", + # when all lines have been processed and have same + # destination + "unload_all", + # when all lines have been processed and have different + # destinations + "unload_single", + } + ) + + def skip_line(self): + return self._response_schema(next_states={"start_line"}) + + def stock_issue(self): + return self._response_schema( + next_states={ + # when we still have lines to process + "start_line", + # when all lines have been processed and have same + # destination + "unload_all", + # when all lines have been processed and have different + # destinations + "unload_single", + } + ) + + def change_pack_lot(self): + return self._response_schema( + next_states={"change_pack_lot", "scan_destination"} + ) + + def set_destination_all(self): + return self._response_schema( + next_states={ + # if the batch still contain lines + "start_line", + # invalid destination, have to scan a valid one + "unload_all", + # this endpoint was called but after checking, lines + # have different destination locations + "unload_single", + # different destination to confirm + "confirm_unload_all", + # batch finished + "start", + } + ) + + def unload_split(self): + return self._response_schema(next_states={"unload_single"}) + + def unload_scan_pack(self): + return self._response_schema( + next_states={ + # go back to the same state if barcode issue + "unload_single", + # if the package to scan was deleted, was the last to unload + # and we still have lines to pick + "start_line", + # next "logical" state, when the scan is ok + "unload_set_destination", + } + ) + + def unload_scan_destination(self): + return self._response_schema( + next_states={ + "unload_single", + "unload_set_destination", + "confirm_unload_set_destination", + "start", + "start_line", + } + ) + + @property + def _schema_for_batch_details(self): + return self.schemas.picking_batch(with_pickings=True) + + @property + def _schema_for_single_line_details(self): + schema = self.schemas.move_line() + schema["picking"] = self.schemas._schema_dict_of(self.schemas.picking()) + schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch()) + schema["scan_location_or_pack_first"] = { + "type": "boolean", + "nullable": False, + "required": False, + } + schema["sublocation"] = self.schemas._schema_dict_of( + self.schemas.location(), nullable=False, required=False + ) + return schema + + @property + def _schema_for_unload_all(self): + schema = self.schemas.picking_batch() + schema["location_dest"] = self.schemas._schema_dict_of(self.schemas.location()) + return schema + + @property + def _schema_for_unload_single(self): + schema = self.schemas.picking_batch() + schema["package"] = self.schemas._schema_dict_of(self.schemas.package()) + schema["location_dest"] = self.schemas._schema_dict_of(self.schemas.location()) + return schema + + @property + def _schema_for_zero_check(self): + schema = { + "id": {"required": True, "type": "integer"}, + } + schema["location_src"] = self.schemas._schema_dict_of(self.schemas.location()) + schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch()) + return schema + + @property + def _schema_for_batch_selection(self): + return self.schemas._schema_search_results_of(self.schemas.picking_batch()) + + @property + def _schema_for_scan_destination(self): + schema = self._schema_for_single_line_details + schema["disable_full_bin_action"] = {"type": "boolean"} + return schema diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py new file mode 100644 index 0000000000..7f4cefccf1 --- /dev/null +++ b/shopfloor/services/delivery.py @@ -0,0 +1,828 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, fields +from odoo.osv import expression +from odoo.tools.float_utils import float_is_zero + +from odoo.addons.base_rest.components.service import to_bool, to_int +from odoo.addons.component.core import Component + + +class Delivery(Component): + """ + Methods for the Delivery Process + + Deliver the goods by processing the PACK and raw products by delivery order. + Last step in the pick/pack/ship steps. (Cluster Picking → Checkout → Delivery) + + Multiple operators could be processing a same delivery order. + + You will find a sequence diagram describing states and endpoints + relationships [here](../docs/delivery_diag_seq.png). + Keep [the sequence diagram](../docs/delivery_diag_seq.plantuml) + up-to-date if you change endpoints. + + Expected: + + * Existing packages are moved to customer location + * Products are moved to customer location as raw products + * Bin packed products are placed in new shipping package and shipped to customer + + Every time a package, product or lot is scanned, the package level and move line + are set to done. When the last line is scanned, the transfer is set to done. + Data for the last transfer for which we have been scanning a line if it is not done. + When a transfer is scanned, it returns its data to be shown on the screen. + + Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.delivery" + _usage = "delivery" + _description = __doc__ + + def _response_for_deliver(self, picking=None, location=None, message=None): + """Transition to the 'deliver' state + + If no picking is passed, the screen shows an empty screen + """ + return self._response( + next_state="deliver", + data={ + "picking": self.data_detail.picking_detail(picking) + if picking + else None, + "sublocation": self.data.location( + location, with_operation_progress=True + ) + if location + else None, + }, + message=message, + ) + + def _response_for_manual_selection(self, pickings, message=None): + """Transition to the 'manual_selection' state + + If no picking is passed, the screen shows an empty screen + """ + return self._response( + next_state="manual_selection", + data={ + "pickings": [ + self.data_detail.picking_detail(picking) for picking in pickings + ], + }, + message=message, + ) + + def _response_for_confirm_done(self, picking, message=None): + """Transition to the 'confirm_done' state.""" + return self._response( + next_state="confirm_done", + data={ + "picking": self.data_detail.picking_detail(picking) + if picking + else None, + }, + message=message, + ) + + def scan_deliver(self, barcode, picking_id=None, location_id=None): + """Scan a stock picking, a package/product/lot or a stock location + + When a stock picking is scanned and is partially or fully available, it + is returned to show its lines. + + When a package is scanned, and has an available move line part of the + expected picking type, the package level is directly set to "done" and + the stock picking of the line is returned to work on its other lines. + + When a stock location is scanned and it is a sub-location of an operation + type allowed on the current shopfloor menu, the next delivery + operations will be put into that location. + + If the barcode is a product or a product's packaging, the move lines + for this product are set to done. However, if the product is in more + than one package, a package barcode is requested, and if the product is + tracked by lot/serial, a lot is asked. + + If the option 'Process as pre-packaged' is enabled on the menu, then + when a product's packaging is scanned, the first move line without + a source package (bulk line) corresponding to the quantity of the + packaging will be set to done. + + If the barcode is a lot, the lines for this lot are set to + done. However, if the lot is in more than one package, a package + barcode is requested. + + NOTE: see scan_line in the Checkout service. + + When all the available move lines of the stock picking are done, the + stock picking is set to done. + + The ``picking_id`` parameter is used to be stateless: if the client + sends a wrong barcode, it allows to stay on the last picking with + updated data (and we really want to refresh data because several + users may work on the same transfer). + + Transitions: + * deliver: always return here with the data for the last touched + picking or no picking if the picking has been set to done + """ + location = ( + self.env["stock.location"].browse(location_id) if location_id else None + ) + if not barcode: + return self._response_for_deliver(location=location) + search = self._actions_for("search") + picking = search.picking_from_scan(barcode) + barcode_valid = bool(picking) + + if picking: + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(location=location, message=message) + + if picking_id: + picking = self.env["stock.picking"].browse(picking_id) + + # Validate picking anyway + if not barcode_valid: + package = search.package_from_scan(barcode) + if package: + return self._deliver_package(picking, package, location) + + if not barcode_valid: + product = search.product_from_scan(barcode) + if product: + return self._deliver_product( + picking, product, product_qty=1, location=location + ) + + if not barcode_valid: + packaging = search.packaging_from_scan(barcode) + if packaging: + # By scanning a packaging, we want to process + # the full quantity of the packaging + packaging_qty = packaging.product_uom_id._compute_quantity( + packaging.qty, packaging.product_id.uom_id + ) + return self._deliver_product( + picking, + packaging.product_id, + product_qty=packaging_qty, + location=location, + ) + + if not barcode_valid: + lot = search.lot_from_scan(barcode) + if lot: + return self._deliver_lot(picking, lot, product_qty=1, location=location) + + if not barcode_valid: + sublocation = search.location_from_scan(barcode) + if sublocation and sublocation.is_sublocation_of( + self.picking_types.mapped("default_location_src_id") + ): + message = self.msg_store.location_src_set_to_sublocation(sublocation) + return self._response_for_deliver(location=sublocation, message=message) + + message = self.msg_store.barcode_not_found() if not barcode_valid else None + return self._response_for_deliver( + picking=picking, location=location, message=message + ) + + def _set_lines_done(self, lines, product_qty=None): + """Set done quantities on `lines`. + + Once all lines of a picking have been processed, the picking will be + validated automatically. + Return `True` if the related picking has been validated. + """ + allow_prepackaged_product = self.work.menu.allow_prepackaged_product + if product_qty: # defined with lot/product/packaging scan + # With a product_qty we process only one move line, + # so one move to deal with regarding the qty + qty_done = lines.move_id.product_id.uom_id._compute_quantity( + product_qty, lines.move_id.product_uom + ) + lines.qty_done += qty_done + return self._action_picking_done( + lines.picking_id, force=allow_prepackaged_product + ) + for line in lines: + # note: the package level is automatically set to "is_done" when + # the qty_done is full + line.qty_done = line.reserved_uom_qty + picking = fields.first(lines.mapped("picking_id")) + return self._action_picking_done(picking, force=allow_prepackaged_product) + + def _reset_lines(self, lines): + for line in lines: + # note: the package level "is_done" field is automatically unset + # when the qty_done is not full + line.qty_done = 0 + + def _deliver_package(self, picking, package, location): + lines = package.move_line_ids.filtered( + lambda l: l.state in ("assigned", "partially_available") + ) + # State of the picking might change while we reach this point: check again! + message = self._check_picking_status(lines.mapped("picking_id")) + if message: + message["body"] = "\n".join( + [ + _("Package {} belongs to a picking without a valid state.").format( + package.name + ), + message["body"], + ] + ) + return self._response_for_deliver(location=location, message=message) + if not lines: + return self._response_for_deliver( + picking=picking, + location=location, + message=self.msg_store.cannot_move_something_in_picking_type(), + ) + # TODO add a message if any of the lines already had a qty_done > 0 + new_picking = fields.first(lines.mapped("picking_id")) + if self._set_lines_done(lines): + return self._response_for_deliver( + location=location, message=self.msg_store.transfer_complete(new_picking) + ) + return self._response_for_deliver(picking=new_picking, location=location) + + def _lines_base_domain(self, no_qty_done=True): + domain = [ + # we added auto_join for this, otherwise, the ORM would search all pickings + # in the picking type, and then use IN (ids) + ("picking_id.picking_type_id", "in", self.picking_types.ids), + ] + if no_qty_done: + domain.append(("qty_done", "=", 0)) + return domain + + def _lines_from_lot_domain( + self, lot, no_qty_done=True, product_qty=None, location=None + ): + location_domain = ( + [("picking_id.location_id", "=", location.id)] if location else [] + ) + domain = expression.AND( + [ + self._lines_base_domain(no_qty_done), + [("lot_id", "=", lot.id)], + location_domain, + ] + ) + if product_qty: + domain.extend( + [ + ("reserved_qty", ">=", product_qty), + ] + ) + return domain + + def _lines_from_product_domain( + self, product, no_qty_done=True, product_qty=None, location=None + ): + # TODO: searching lines is common to other scenario, to refactor + domain = expression.AND( + [self._lines_base_domain(no_qty_done), [("product_id", "=", product.id)]] + ) + if location: + domain.extend([("location_id", "=", location.id)]) + if product_qty: + domain.extend( + [ + ("reserved_qty", ">=", product_qty), + ] + ) + return domain + + def _lines_from_package_domain(self, package, no_qty_done=True): + return expression.AND( + [self._lines_base_domain(no_qty_done), [("package_id", "=", package.id)]] + ) + + def _deliver_product(self, picking, product, product_qty=None, location=None): + """Handle the scan_deliver end point for a product.""" + if product.tracking in ("lot", "serial"): + return self._response_for_deliver( + picking, + location=location, + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + + lines = self.env["stock.move.line"].search( + self._lines_from_product_domain( + product, no_qty_done=False, product_qty=product_qty, location=location + ), + order="date_planned", + ) + if not lines: + return self._response_for_deliver( + picking, + location=location, + message=self.msg_store.product_not_found_in_pickings(), + ) + + multiple_location = ( + not location and len(lines.mapped("picking_id.location_id")) > 1 + ) + if multiple_location: + return self._response_for_deliver( + picking, + location=location, + message=self.msg_store.product_in_multiple_sublocation(product), + ) + + # State of the picking might change while we reach this point: check again! + message = self._check_picking_status(lines.mapped("picking_id")) + if message: + message["body"] = "\n".join( + [ + _("Product {} belongs to a picking without a valid state.").format( + product.name + ), + message["body"], + ] + ) + return self._response_for_deliver(location=location, message=message) + + new_picking = fields.first(lines.mapped("picking_id")) + # When products are as units outside of packages, we can select them for + # packing, but if they are in a package, we want the user to scan the packages. + # If the product is only in one package though, scanning the product selects + # the package. + packages = lines.mapped("package_id") + # Do not use mapped here: we want to see if we have more than one package, + # but also if we have one product as a package and the same product as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({m.package_id for m in lines}) > 1: + return self._response_for_deliver( + new_picking, + location=location, + message=self.msg_store.product_multiple_packages_scan_package(), + ) + elif packages: + # we have 1 package + # abort the operation if the package contain more than one product + if len(packages.mapped("quant_ids.product_id")) > 1: + return self._response_for_deliver( + new_picking, + location=location, + message=self.msg_store.product_mixed_package_scan_package(), + ) + # abort if the quantity is bigger than one + if sum(packages.quant_ids.mapped("reserved_quantity")) > 1: + return self._response_for_deliver( + new_picking, + location=location, + message=self.msg_store.product_not_unitary_in_package_scan_package(), + ) + # We focus only on lines on which we can increase the 'qty_done' + lines = lines.filtered( + lambda l: (l.qty_done + product_qty) <= l.reserved_uom_qty + ) + # Filter lines to keep only ones from one delivery operation + # (we do not want to process lines of another delivery operation) + lines = lines._filter_on_picking(picking) + # We want to process 1 qty of one line + lines = fields.first(lines) + # Validate lines (this will validate the delivery if all lines are processed) + if self._set_lines_done(lines, product_qty): + return self._response_for_deliver( + location=location, message=self.msg_store.transfer_complete(new_picking) + ) + return self._response_for_deliver(new_picking, location=location) + + def _deliver_lot(self, picking, lot, product_qty=None, location=None): + lines = self.env["stock.move.line"].search( + self._lines_from_lot_domain( + lot, no_qty_done=False, product_qty=product_qty, location=location + ) + ) + if not lines: + return self._response_for_deliver( + picking, + location=location, + message=self.msg_store.lot_not_found_in_pickings(), + ) + + multiple_location = ( + not location and len(lines.mapped("picking_id.location_id")) > 1 + ) + if multiple_location: + return self._response_for_deliver( + picking, + location=location, + message=self.msg_store.lot_in_multiple_sublocation(lot), + ) + + # State of the picking might change while we reach this point: check again! + message = self._check_picking_status(lines.mapped("picking_id")) + if message: + message["body"] = "\n".join( + [ + _("Lot {} belongs to a picking without a valid state.").format( + lot.name + ), + message["body"], + ] + ) + return self._response_for_deliver(location=location, message=message) + + new_picking = fields.first(lines.mapped("picking_id")) + + # When lots are as units outside of packages, we can select them for + # packing, but if they are in a package, we want the user to scan the packages. + # If the product is only in one package though, scanning the lot selects + # the package. + packages = lines.mapped("package_id") + # Do not use mapped here: we want to see if we have more than one + # package, but also if we have one lot as a package and the same lot as + # a unit in another line. In both cases, we want the user to scan the + # package. + if packages and len({m.package_id for m in lines}) > 1: + return self._response_for_deliver( + new_picking, + location=location, + message=self.msg_store.lot_multiple_packages_scan_package(), + ) + elif packages: + # we have 1 package + # abort the operation if the package contain more than one product + if len(packages.quant_ids) > 1: + return self._response_for_deliver( + new_picking, + location=location, + message=self.msg_store.lot_mixed_package_scan_package(), + ) + + # Filter lines to keep only ones from one delivery operation + # (we do not want to process lines of another delivery operation) + lines = lines._filter_on_picking(picking) + # We want to process 1 qty of one line + lines = fields.first(lines) + if self._set_lines_done(lines, product_qty=product_qty): + return self._response_for_deliver( + location=location, message=self.msg_store.transfer_complete(new_picking) + ) + return self._response_for_deliver(new_picking, location) + + def _action_picking_done(self, picking, force=False): + """Try to validate the stock picking if all quantities are satisfied. + + Return `True` if the picking has been validated successfully. + + :param picking: stock.picking recordset + :param force: bypass check and set picking as done no matter if satisfied. + You will likely get a backorder for not processed lines. + """ + if picking.state == "done": + return True + if force: + picking._action_done() + return True + all_done = False + for move in picking.move_ids: + if move.state in ("done", "cancel"): + continue + all_done = move._qty_is_satisfied() + if not all_done: + # At least one move not satisfied, cannot mark as done automatically + break + if all_done: + picking._action_done() + return True + return False + + def list_stock_picking(self, message=None, location_id=None): + """Return the list of stock pickings for the picking types + + It returns only stock picking available or partially available. + + Transitions: + * manual_selection: next state to show the list of stock pickings + """ + pickings = self.env["stock.picking"].search( + self._pickings_domain(location_id), order="id" + ) + return self._response_for_manual_selection(pickings, message=message) + + def _pickings_domain(self, location_id=None): + domain = [ + ("picking_type_id", "in", self.picking_types.ids), + ("state", "=", "assigned"), + ] + if location_id: + domain.append(("location_id", "=", location_id)) + return domain + + def select(self, picking_id): + """Select a stock picking from its ID (found using /list_stock_picking) + + It returns only stock picking available or partially available. + + Transitions: + * manual_selection: the selected stock picking is no longer valid + * deliver: with information about the stock.picking + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self.list_stock_picking(message=message) + if picking: + return self._response_for_deliver(picking) + return self.list_stock_picking(message=self.msg_store.stock_picking_not_found()) + + def set_qty_done_pack(self, picking_id, package_id, location_id=None): + """Set a package to "Done" + + When all the available move lines of the stock picking are done, the + stock picking is set to done. + + Transitions: + * deliver: always return here with updated data + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) + package = self.env["stock.quant.package"].browse(package_id).exists() + if package: + response = self._deliver_package(picking, package, location_id) + self._action_picking_done(picking) + return response + return self._response_for_deliver( + picking=picking, message=self.msg_store.package_not_found() + ) + + def set_qty_done_line(self, picking_id, move_line_id): + """Set a move line to "Done" + + Should be called only for lines of raw products, /set_qty_done_pack + must be used for lines that move a package. + + When all the available move lines of the stock picking are done, the + stock picking is set to done. + + Transitions: + * deliver: always return here with updated data + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) + line = self.env["stock.move.line"].browse(move_line_id).exists() + if line: + if line.package_id: + return self._response_for_deliver( + picking=picking, + message=self.msg_store.line_has_package_scan_package(), + ) + if self._set_lines_done(line): + return self._response_for_deliver( + message=self.msg_store.transfer_complete(picking) + ) + return self._response_for_deliver(picking) + return self._response_for_deliver( + picking=picking, + message=self.msg_store.record_not_found(), + ) + + def reset_qty_done_pack(self, picking_id, package_id): + """Remove "Done" on a package + + Transitions: + * deliver: always return here with updated data + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) + package = self.env["stock.quant.package"].browse(package_id).exists() + if package: + lines = self.env["stock.move.line"].search( + self._lines_from_package_domain(package, no_qty_done=False) + ) + if not lines: + return self._response_for_deliver( + picking, + message=self.msg_store.package_not_available_in_picking( + package, picking + ), + ) + self._reset_lines(lines) + return self._response_for_deliver(picking) + return self._response_for_deliver( + picking=picking, message=self.msg_store.package_not_found() + ) + + def reset_qty_done_line(self, picking_id, move_line_id): + """Remove "Done" on a move line + + Should be called only for lines of raw products, /set_qty_done_pack + must be used for lines that move a package. + + Transitions: + * deliver: always return here with updated data + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) + line = self.env["stock.move.line"].browse(move_line_id).exists() + if line: + if line.picking_id != picking: + return self._response_for_deliver( + picking=picking, + message=self.msg_store.line_not_available_in_picking(picking), + ) + if line.package_id: + return self._response_for_deliver( + picking=picking, + message=self.msg_store.line_has_package_scan_package(), + ) + self._reset_lines(line) + return self._response_for_deliver(picking) + return self._response_for_deliver( + picking=picking, + message=self.msg_store.record_not_found(), + ) + + def done(self, picking_id, confirm=False): + """Set the stock picking to done + + Transitions: + * deliver: error during action + * confirm_done: when not all lines of the stock.picking are done + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_deliver(message=message) + if self._action_picking_done(picking): + return self._response_for_deliver( + message=self.msg_store.transfer_complete(picking) + ) + if confirm: + precision_digits = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + no_quantities_done = all( + float_is_zero(move_line.qty_done, precision_digits=precision_digits) + for move_line in picking.move_line_ids.filtered( + lambda m: m.state not in ("done", "cancel") + ) + ) + if no_quantities_done: + return self._response_for_deliver( + message=self.msg_store.transfer_no_qty_done() + ) + self._action_picking_done(picking, force=True) + return self._response_for_deliver( + message=self.msg_store.transfer_complete(picking) + ) + return self._response_for_confirm_done( + picking, + message=self.msg_store.transfer_confirm_done(), + ) + + +class ShopfloorDeliveryValidator(Component): + """Validators for the Delivery endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.delivery.validator" + _usage = "delivery.validator" + + def scan_deliver(self): + return { + "barcode": {"required": True, "type": "string"}, + "picking_id": { + "coerce": to_int, + "required": False, + "nullable": True, + "type": "integer", + }, + "location_id": { + "coerce": to_int, + "required": False, + "nullable": True, + "type": "integer", + }, + } + + def list_stock_picking(self): + return { + "location_id": { + "coerce": to_int, + "required": False, + "nullable": True, + "type": "integer", + }, + } + + def select(self): + return {"picking_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def set_qty_done_pack(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def set_qty_done_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def reset_qty_done_pack(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def reset_qty_done_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def done(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "confirm": {"coerce": to_bool, "required": False, "type": "boolean"}, + } + + +class ShopfloorDeliveryValidatorResponse(Component): + """Validators for the Delivery endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.delivery.validator.response" + _usage = "delivery.validator.response" + + _start_state = "deliver" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "deliver": self._schema_deliver, + "manual_selection": self._schema_selection_list, + "confirm_done": self._schema_deliver, + } + + @property + def _schema_deliver(self): + schema_picking = self.schemas_detail.picking_detail() + schema_location = self.schemas.location() + return { + "picking": {"type": "dict", "nullable": True, "schema": schema_picking}, + "sublocation": { + "type": "dict", + "nullable": True, + "schema": schema_location, + }, + } + + @property + def _schema_selection_list(self): + schema = self.schemas_detail.picking_detail() + return { + "pickings": {"type": "list", "schema": {"type": "dict", "schema": schema}} + } + + def scan_deliver(self): + return self._response_schema(next_states={"deliver"}) + + def list_stock_picking(self): + return self._response_schema(next_states={"manual_selection"}) + + def select(self): + return self._response_schema(next_states={"deliver", "manual_selection"}) + + def set_qty_done_pack(self): + return self._response_schema(next_states={"deliver"}) + + def set_qty_done_line(self): + return self._response_schema(next_states={"deliver"}) + + def reset_qty_done_pack(self): + return self._response_schema(next_states={"deliver"}) + + def reset_qty_done_line(self): + return self._response_schema(next_states={"deliver"}) + + def done(self): + return self._response_schema(next_states={"deliver", "confirm_done"}) diff --git a/shopfloor/services/forms/__init__.py b/shopfloor/services/forms/__init__.py new file mode 100644 index 0000000000..8acf220a82 --- /dev/null +++ b/shopfloor/services/forms/__init__.py @@ -0,0 +1 @@ +from . import picking_form diff --git a/shopfloor/services/forms/picking_form.py b/shopfloor/services/forms/picking_form.py new file mode 100644 index 0000000000..6390120c5f --- /dev/null +++ b/shopfloor/services/forms/picking_form.py @@ -0,0 +1,78 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + + +class ShopfloorPickingForm(Component): + """Allow to modify a stock.picking. + + Editable fields: carrier_id. + """ + + _inherit = "shopfloor.form.mixin" + _name = "shopfloor.form.stock.picking" + _usage = "form_edit_stock_picking" + _description = __doc__ + _expose_model = "stock.picking" + + def _record_data(self, record): + # TODO: we use _detail here because it has the carrier info + # but is plenty of data we don't need -> add specific schema for forms + return self.data_detail.picking_detail(record) + + def _form_data(self, record): + data = {} + available_carriers = self._get_available_carriers(record) + data["carrier_id"] = { + "value": record.carrier_id.id, + "select_options": available_carriers.jsonify(["id", "name"]), + } + return data + + def _get_available_carriers(self, record): + company_carriers = self.env["delivery.carrier"].search( + ["|", ("company_id", "=", False), ("company_id", "=", record.company_id.id)] + ) + available_carriers = company_carriers.available_carriers(record.partner_id) + return available_carriers + + +class ShopfloorPickingFormValidator(Component): + """Validators for the ShopfloorPickingForm endpoints""" + + _inherit = "shopfloor.form.validator.mixin" + _name = "shopfloor.form.stock.picking.validator" + _usage = "form_edit_stock_picking.validator" + + def update(self): + schema = super().update() + schema.update( + { + "carrier_id": {"type": "integer", "required": True}, + } + ) + return schema + + +class ShopfloorPickingFormValidatorResponse(Component): + """Validators for the ShopfloorPickingForm endpoints responses""" + + _inherit = "shopfloor.form.validator.response.mixin" + _name = "shopfloor.form.stock.picking.validator.response" + _usage = "form_edit_stock_picking.validator.response" + + def _form_schema(self): + return { + "carrier_id": self.schemas._schema_dict_of( + { + "value": {"type": "integer", "required": True}, + "select_options": self.schemas._schema_list_of( + self.schemas._simple_record() + ), + } + ) + } + + def _record_schema(self): + return self.schemas_detail.picking_detail() diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py new file mode 100644 index 0000000000..97e010a062 --- /dev/null +++ b/shopfloor/services/location_content_transfer.py @@ -0,0 +1,1194 @@ +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2022 Jacques-Etienne Baudoux (BCIM) +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://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 ..utils import to_float + +# NOTE for the implementation: share several similarities with the "cluster +# picking" scenario + + +# TODO add picking and package content in package level? + + +class LocationContentTransfer(Component): + """ + Methods for the Location Content Transfer Process + + Move the full content of a location to one or more locations. + + Generally used to move a pallet with multiple boxes to either: + + * 1 destination location, unloading the full pallet + * To multiple destination locations, unloading one product/lot per + locations + * To multiple destination locations, unloading one product/lot per + locations and then unloading all remaining product/lot to a single final + destination + + The move lines must exist beforehand, the workflow only moves them. + + Expected: + + * All move lines and package level have a destination set, and are done. + + 2 complementary actions are possible on the screens allowing to move a line: + + * Declare a stock out for a product or package (nothing found in the + location) + * Skip to the next line (will be asked again at the end) + + Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.location.content.transfer" + _usage = "location_content_transfer" + _description = __doc__ + + _advisory_lock_find_work = "location_content_transfer_find_work" + + def _response_for_start(self, message=None, popup=None): + """Transition to the 'start' or 'get_work' state + + The switch to 'get_work' is done if the option is enabled on the scenario + """ + if self.work.menu.allow_get_work: + return self._response( + next_state="get_work", data={}, message=message, popup=popup + ) + return self._response(next_state="scan_location", message=message, popup=popup) + + def _response_for_scan_location(self, location=None, message=None): + """Transition to the 'scan_location' state + + If location is set, the client will display information on that location + and only accept this specific location to be scanned. + """ + data = {} + if location: + data["location"] = self.data.location(location) + return self._response( + next_state="scan_location", + data=data, + message=message, + ) + + def _response_for_scan_destination_all( + self, pickings, message=None, confirmation_required=False + ): + """Transition to the 'scan_destination_all' state + + The client screen shows a summary of all the lines and packages + to move to a single destination. + + If `confirmation_required` is set, + the client will ask to scan again the destination + """ + data = self._data_content_all_for_location(pickings=pickings) + data["confirmation_required"] = confirmation_required + if confirmation_required and not message: + message = self.msg_store.need_confirmation() + return self._response( + next_state="scan_destination_all", data=data, message=message + ) + + def _response_for_start_single(self, pickings, message=None, popup=None): + """Transition to the 'start_single' state + + The client screen shows details of the package level or move line to move. + """ + location = pickings.mapped("location_id") + next_content = self._next_content(pickings) + if not next_content: + # TODO test (no more lines) + return self._response_for_start(message=message, popup=popup) + return self._response( + next_state="start_single", + data=self._data_content_line_for_location(location, next_content), + message=message, + popup=popup, + ) + + def _response_for_scan_destination( + self, location, next_content, message=None, confirmation_required=False + ): + """Transition to the 'scan_destination' state + + The client screen shows details of the package level or move line to move. + """ + data = self._data_content_line_for_location(location, next_content) + data["confirmation_required"] = confirmation_required + if confirmation_required and not message: + message = self.msg_store.need_confirmation() + return self._response(next_state="scan_destination", data=data, message=message) + + def _data_content_all_for_location(self, pickings): + sorter = self._actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + lines = sorter.move_lines() + package_levels = sorter.package_levels() + location = pickings.mapped("move_line_ids.location_id") + assert len(location) == 1, "There should be only one src location at this stage" + return { + "location": self.data.location(location), + "move_lines": self.data.move_lines(lines), + "package_levels": self.data.package_levels(package_levels), + } + + def _data_content_line_for_location(self, location, next_content): + assert next_content._name in ("stock.move.line", "stock.package_level") + line_data = ( + self.data.move_line(next_content) + if next_content._name == "stock.move.line" + else None + ) + level_data = ( + self.data.package_level(next_content) + if next_content._name == "stock.package_level" + else None + ) + return {"move_line": line_data, "package_level": level_data} + + def _next_content(self, pickings): + sorter = self._actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + try: + next_content = next(sorter) + except StopIteration: + # TODO set picking to done + return None + return next_content + + def _router_single_or_all_destination(self, pickings, message=None): + location_dest = pickings.mapped("move_line_ids.location_dest_id") + location_src = pickings.mapped("move_line_ids.location_id") + if len(location_dest) == len(location_src) == 1: + return self._response_for_scan_destination_all(pickings, message=message) + else: + return self._response_for_start_single(pickings, message=message) + + def _domain_recover_pickings(self): + return [ + ("user_id", "=", self.env.uid), + ("state", "=", "assigned"), + ("picking_type_id", "in", self.picking_types.ids), + ] + + def _search_recover_pickings(self): + candidate_pickings = self.env["stock.picking"].search( + self._domain_recover_pickings() + ) + started_pickings = candidate_pickings.filtered( + lambda picking: any(line.qty_done for line in picking.move_line_ids) + ) + return started_pickings + + def _recover_started_picking(self): + """Get the next response if the user has work in progress.""" + started_pickings = self._search_recover_pickings() + if not started_pickings: + return False + return self._router_single_or_all_destination( + started_pickings, message=self.msg_store.recovered_previous_session() + ) + + def start_or_recover(self): + """Start a new session or recover an existing one + + If the current user had transfers in progress in this scenario + and reopen the menu, we want to directly reopen the screens to choose + destinations. Otherwise, we go to the "start" state. + """ + response = self._recover_started_picking() + return response or self._response_for_start() + + def _find_location_move_lines_domain(self, location): + return [ + ("location_id", "=", location.id), + ("qty_done", "=", 0), + ("state", "in", ("assigned", "partially_available")), + ("picking_id.user_id", "in", (False, self.env.uid)), + ("picking_id.state", "=", "assigned"), + ] + + def _find_location_move_lines_from_scan_location(self, *args, **kwargs): + return self._find_location_move_lines(*args, **kwargs) + + def _find_location_move_lines(self, location): + """Find lines that potentially are to move in the location""" + return self.env["stock.move.line"].search( + self._find_location_move_lines_domain(location) + ) + + def _create_moves_from_location(self, location): + # get all quants from the scanned location + quants = self.env["stock.quant"].search( + [("location_id", "=", location.id), ("quantity", ">", 0)] + ) + # create moves for each quant + picking_type = self.picking_types + move_vals_list = [] + for quant in quants: + move_vals_list.append( + { + "name": quant.product_id.name, + "company_id": picking_type.company_id.id, + "product_id": quant.product_id.id, + "product_uom": quant.product_uom_id.id, + "product_uom_qty": quant.quantity, + "location_id": location.id, + "location_dest_id": picking_type.default_location_dest_id.id, + "origin": self.work.menu.name, + "picking_type_id": picking_type.id, + } + ) + return self.env["stock.move"].create(move_vals_list) + + def _find_location_to_work_from(self): + location = self.env["stock.location"] + pickings = self.env["stock.picking"].search( + [ + ("picking_type_id", "in", self.picking_types.ids), + ("state", "=", "assigned"), + ("user_id", "in", (False, self.env.user.id)), + ], + order="user_id, priority desc, scheduled_date asc, id desc", + ) + + for next_picking in pickings: + move_lines = next_picking.move_line_ids.filtered( + lambda line: line.qty_done < line.reserved_uom_qty + ) + location = fields.first(move_lines).location_id + if location: + break + return location + + def find_work(self): + """Find the next location to work from, for a user. + + First recover any started pickings. + The find the first move line from the oldest transfer that can be worked on. + Mark all move lines on that location as picked. + And ask the user to confirm. + + Transitions: + * start: no work found + * scan_location: with the location to work form for confirmation + """ + response = self._recover_started_picking() + if response: + return response + self._actions_for("lock").advisory(self._advisory_lock_find_work) + location = self._find_location_to_work_from() + if not location: + return self._response_for_start(message=self.msg_store.no_work_found()) + move_lines = self._find_location_move_lines(location) + stock = self._actions_for("stock") + stock.mark_move_line_as_picked(move_lines, quantity=0) + return self._response_for_scan_location(location=location) + + def _find_move_lines_to_cancel_work(self, location): + unreserve = self._actions_for("stock.unreserve") + return self.env["stock.move.line"].search( + unreserve._find_location_all_move_lines_domain(location) + ) + + def _move_lines_cancel_work(self, move_lines): + move_lines.write({"shopfloor_user_id": False}) + move_lines.mapped("picking_id").write({"user_id": False}) + stock = self._actions_for("stock") + stock.unmark_move_line_as_picked(move_lines) + + def cancel_work(self, location_id): + """Cancel work marked as picked by the user. + + Transitions: + * start: + """ + location = self.env["stock.location"].browse(location_id) + if not location: + return self._response_for_start(message=self.msg_store.location_not_found()) + + move_lines = self._find_move_lines_to_cancel_work(location) + self._move_lines_cancel_work(move_lines) + return self._response_for_start() + + def scan_location(self, barcode): # noqa: C901 + """Scan start location + + Called at the beginning at the workflow to select the location from which + we want to move the content. + + All the move lines and package levels must have the same picking type. + + If the scanned location has no move lines, new move lines to move the + whole content of the location are created if: + + * the menu has the option "Allow to create move(s)" + * the menu is linked to only one picking type. + + When move lines and package levels have different destinations, the + first line without package level or package level is sent to the client. + + The selected move lines to process are bound to the current operator, + this will allow another operator to find unprocessed lines in parallel + and not overlap with current ones. + + Transitions: + * start: location not found, ... + * scan_destination_all: if the destination of all the lines and package + levels have the same destination + * start_single: if any line or package level has a different destination + """ + location = self._actions_for("search").location_from_scan(barcode) + if not location: + return self._response_for_start(message=self.msg_store.barcode_not_found()) + + if not self.is_src_location_valid(location): + return self._response_for_start( + message=self.msg_store.cannot_move_something_in_picking_type() + ) + + move_lines = self._find_location_move_lines_from_scan_location(location) + + savepoint = self._actions_for("savepoint").new() + unreserve = self._actions_for("stock.unreserve") + + unreserved_moves = self.env["stock.move"].browse() + if self.work.menu.allow_unreserve_other_moves: + message = unreserve.check_unreserve(location, move_lines) + if message: + return self._response_for_start(message=message) + move_lines, unreserved_moves = unreserve.unreserve_moves( + move_lines, self.picking_types + ) + else: + picking_types = move_lines.picking_id.picking_type_id + if len(picking_types) > 1: + return self._response_for_start( + message={ + "message_type": "error", + "body": _("This location content can't be moved at once."), + } + ) + if picking_types - self.picking_types: + return self._response_for_start( + message=self.msg_store.cannot_move_something_in_picking_type() + ) + + if not move_lines: + if not self.is_allow_move_create(): + savepoint.rollback() + return self._response_for_start( + message=self.msg_store.location_empty(location) + ) + new_moves = self._create_moves_from_location(location) + if not new_moves: + savepoint.rollback() + return self._response_for_start( + message=self.msg_store.location_empty(location) + ) + new_moves._action_confirm(merge=False) + new_moves._action_assign() + if not all([x.state == "assigned" for x in new_moves]): + savepoint.rollback() + return self._response_for_start( + message=self.msg_store.new_move_lines_not_assigned() + ) + move_lines = new_moves.move_line_ids + for line in move_lines: + if not self.is_dest_location_valid(line.move_id, line.location_dest_id): + savepoint.rollback() + return self._response_for_start( + message=self.msg_store.location_content_unable_to_transfer( + location + ) + ) + + stock = self._actions_for("stock") + if self.work.menu.ignore_no_putaway_available and stock.no_putaway_available( + self.picking_types, move_lines + ): + # the putaway created a move line but no putaway was possible, so revert + # to the initial state + savepoint.rollback() + return self._response_for_start( + message=self.msg_store.no_putaway_destination_available() + ) + + stock.mark_move_line_as_picked(move_lines) + + unreserved_moves._action_assign() + + savepoint.release() + + return self._router_single_or_all_destination(move_lines.picking_id) + + def _find_transfer_move_lines_domain(self, location): + return [ + ("location_id", "=", location.id), + ("state", "in", ("assigned", "partially_available")), + ("qty_done", ">", 0), + # TODO check generated SQL + ("picking_id.user_id", "=", self.env.uid), + ] + + def _find_transfer_move_lines(self, location): + """Find move lines currently being moved by the user""" + lines = self.env["stock.move.line"].search( + self._find_transfer_move_lines_domain(location) + ) + return lines + + # hook used in module shopfloor_checkout_sync + def _write_destination_on_lines(self, lines, location): + lines.location_dest_id = location + lines.package_level_id.picking_id.location_dest_id = location + + def _set_all_destination_lines_and_done(self, pickings, move_lines, dest_location): + self._write_destination_on_lines(move_lines, dest_location) + stock = self._actions_for("stock") + stock.validate_moves(move_lines.move_id) + + def _lock_lines(self, lines): + """Lock move lines""" + self._actions_for("lock").for_update(lines) + + def set_destination_all(self, location_id, barcode, confirmation=False): + """Scan destination location for all the moves of the location + + barcode is a stock.location for the destination + + Transitions: + * scan_destination_all: invalid destination or could not set moves to done + * start: moves are done + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_lines = self._find_transfer_move_lines(location) + pickings = move_lines.mapped("picking_id") + if not pickings: + # if we can't find the lines anymore, they likely have been done + # by someone else + return self._response_for_start(message=self.msg_store.already_done()) + scanned_location = self._actions_for("search").location_from_scan(barcode) + if not scanned_location: + return self._response_for_scan_destination_all( + pickings, message=self.msg_store.barcode_not_found() + ) + + if not self.is_dest_location_valid(move_lines.move_id, scanned_location): + return self._response_for_scan_destination_all( + pickings, message=self.msg_store.dest_location_not_allowed() + ) + if not confirmation and self.is_dest_location_to_confirm( + move_lines.location_dest_id, scanned_location + ): + return self._response_for_scan_destination_all( + pickings, confirmation_required=True + ) + self._lock_lines(move_lines) + + self._set_all_destination_lines_and_done(pickings, move_lines, scanned_location) + + completion_info = self._actions_for("completion.info") + completion_info_popup = completion_info.popup(move_lines) + return self._response_for_start( + message=self.msg_store.location_content_transfer_complete( + location, scanned_location + ), + popup=completion_info_popup, + ) + + def go_to_single(self, location_id): + """Ask the first move line or package level + + If the user was brought to the screen allowing to move everything to + the same location, but they want to move them to different locations, + this method will return the first move line or package level. + + Transitions: + * start: no remaining lines in the location + * start_single: if any line or package level has a different destination + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_lines = self._find_transfer_move_lines(location) + if not move_lines: + return self._response_for_start( + message=self.msg_store.no_lines_to_process() + ) + return self._response_for_start_single(move_lines.mapped("picking_id")) + + def scan_package(self, location_id, package_level_id, barcode): + """Scan a package level to move + + It validates that the user scanned the correct package, lot or product. + + Transitions: + * start: no remaining lines in the location + * start_single: barcode not found, ... + * scan_destination: the barcode matches + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.record_not_found(), + ) + + search = self._actions_for("search") + package = search.package_from_scan(barcode) + if package and package_level.package_id == package: + return self._response_for_scan_destination(location, package_level) + + move_lines = self._find_transfer_move_lines(location) + package_move_lines = package_level.move_line_ids + other_move_lines = move_lines - package_move_lines + + product = search.product_from_scan(barcode) + if not product: + packaging = search.packaging_from_scan(barcode) + product = packaging.product_id + # Normally the user scan the barcode of the package. But if they scan the + # product and we can be sure it's the correct package, it's tolerated. + if product and product in package_move_lines.mapped("product_id"): + if product in other_move_lines.mapped("product_id") or product.tracking in ( + "lot", + "serial", + ): + # When the product exists in other move lines as raw products + # or part of another package, we can't be sure they scanned + # the correct package, so ask to scan the package. + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message={"message_type": "error", "body": _("Scan the package")}, + ) + else: + return self._response_for_scan_destination(location, package_level) + + lot = search.lot_from_scan(barcode, products=package_move_lines.product_id) + if lot and lot in package_move_lines.mapped("lot_id"): + if lot in other_move_lines.mapped("lot_id"): + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message={"message_type": "error", "body": _("Scan the package")}, + ) + else: + return self._response_for_scan_destination(location, package_level) + + # Nothing matches what is expected from the move line. + for rec in (package, product, lot): + if rec: + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.wrong_record(rec), + ) + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=self.msg_store.barcode_not_found() + ) + + def scan_line(self, location_id, move_line_id, barcode): + """Scan a move line to move + + It validates that the user scanned the correct package, lot or product. + + Transitions: + * start: no remaining lines in the location + * start_single: barcode not found, ... + * scan_destination: the barcode matches + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.record_not_found(), + ) + + search = self._actions_for("search") + + package = search.package_from_scan(barcode) + if package and move_line.package_id == package: + # In case we have a source package but no package level because if + # we have a package level, we would use "scan_package". + return self._response_for_scan_destination(location, move_line) + + product = search.product_from_scan(barcode) + if not product: + packaging = search.packaging_from_scan(barcode) + if packaging: + product = packaging.product_id + + if product and product == move_line.product_id: + if product.tracking in ("lot", "serial"): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + else: + return self._response_for_scan_destination(location, move_line) + + lot = search.lot_from_scan(barcode, products=move_line.product_id) + if lot and lot == move_line.lot_id: + return self._response_for_scan_destination(location, move_line) + + # Nothing matches what is expected from the move line. + move_lines = self._find_transfer_move_lines(location) + for rec in (package, product, lot): + if rec: + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.wrong_record(rec), + ) + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=self.msg_store.barcode_not_found() + ) + + def set_destination_package( + self, location_id, package_level_id, barcode, confirmation=False + ): + """Scan destination location for package level + + If the move has other move lines / package levels it has to be split + so we can post only this part. + + After the destination is set, the move is set to done. + + Transitions: + * scan_destination: invalid destination or could not + * start_single: continue with the next package level / line + * start: if there is no more package level / line to process + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) + search = self._actions_for("search") + scanned_location = search.location_from_scan(barcode) + if not scanned_location: + return self._response_for_scan_destination( + location, package_level, message=self.msg_store.no_location_found() + ) + package_moves = package_level.move_line_ids.move_id + if not self.is_dest_location_valid(package_moves, scanned_location): + return self._response_for_scan_destination( + location, + package_level, + message=self.msg_store.dest_location_not_allowed(), + ) + if not confirmation and self.is_dest_location_to_confirm( + package_level.location_dest_id, scanned_location + ): + return self._response_for_scan_destination( + location, package_level, confirmation_required=True + ) + package_move_lines = package_level.move_line_ids + self._lock_lines(package_move_lines) + stock = self._actions_for("stock") + stock.put_package_level_in_move(package_level) + self._write_destination_on_lines(package_level.move_line_ids, scanned_location) + stock.validate_moves(package_moves) + move_lines = self._find_transfer_move_lines(location) + message = self.msg_store.location_content_transfer_item_complete( + scanned_location + ) + completion_info = self._actions_for("completion.info") + completion_info_popup = completion_info.popup(package_moves.move_line_ids) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=message, + popup=completion_info_popup, + ) + + def set_destination_line( + self, location_id, move_line_id, quantity, barcode, confirmation=False + ): + """Scan destination location for move line + + If the quantity < qty of the line, split the move and reserve it. + If the move has other move lines / package levels it has to be split + so we can post only this part. + + After the destination and quantity are set, the move is set to done. + + Transitions: + * scan_destination: invalid destination or could not + * start_single: continue with the next package level / line + * start: if there is no more package level / line to process + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) + search = self._actions_for("search") + scanned_location = search.location_from_scan(barcode) + if not scanned_location: + return self._response_for_scan_destination( + location, move_line, message=self.msg_store.no_location_found() + ) + if not self.is_dest_location_valid(move_line.move_id, scanned_location): + return self._response_for_scan_destination( + location, move_line, message=self.msg_store.dest_location_not_allowed() + ) + if not confirmation and self.is_dest_location_to_confirm( + move_line.location_dest_id, scanned_location + ): + return self._response_for_scan_destination( + location, move_line, confirmation_required=True + ) + + self._lock_lines(move_line) + + move_line.qty_done = quantity + self._write_destination_on_lines(move_line, scanned_location) + + stock = self._actions_for("stock") + + backorders = stock.validate_moves(move_line.move_id) + if backorders: + for move_line in backorders.mapped("move_line_ids"): + move_line.qty_done = move_line.reserved_uom_qty + backorders.user_id = self.env.user + # process first backorder of current line + move_lines = backorders.move_line_ids + else: + move_lines = self._find_transfer_move_lines(move_line.location_id) + message = self.msg_store.location_content_transfer_item_complete( + scanned_location + ) + completion_info = self._actions_for("completion.info") + completion_info_popup = completion_info.popup(move_line) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=message, + popup=completion_info_popup, + ) + + def postpone_package(self, location_id, package_level_id): + """Mark a package level as postponed and return the next level/line + + Transitions: + * start_single: continue with the next package level / line + """ + location = self.env["stock.location"].browse(location_id) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_lines = self._find_transfer_move_lines(location) + if package_level.exists(): + pickings = move_lines.mapped("picking_id") + sorter = self._actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + package_levels = sorter.package_levels() + package_level.shopfloor_postpone(move_lines, package_levels) + return self._response_for_start_single(move_lines.mapped("picking_id")) + + def postpone_line(self, location_id, move_line_id): + """Mark a move line as postponed and return the next level/line + + Transitions: + * start_single: continue with the next package level / line + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + move_lines = self._find_transfer_move_lines(location) + if move_line.exists(): + pickings = move_lines.mapped("picking_id") + sorter = self._actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + package_levels = sorter.package_levels() + move_line.shopfloor_postpone(move_lines, package_levels) + return self._response_for_start_single(move_lines.mapped("picking_id")) + + def stock_out_package(self, location_id, package_level_id): + """Declare a stock out on a package level + + It first ensures the stock.move only has this package level. If not, it + splits the move to have no side-effect on the other package levels/move + lines. + + It unreserves the move, create an inventory at 0 in the move's source + location, create a second draft inventory (if none exists) to check later. + Finally, it cancels the move. + + Transitions: + * start: no more content to move + * start_single: continue with the next package level / line + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) + inventory = self._actions_for("inventory") + package_move_lines = package_level.move_line_ids + package_moves = package_move_lines.mapped("move_id") + package = package_level.package_id + for package_move in package_moves: + # Check if there is no other lines linked to the move others than + # the lines related to the package itself. In such case we have to + # split the move to process only the lines related to the package. + package_move.split_other_move_lines(package_move_lines) + lot = package_move.move_line_ids.lot_id + # We need to set qty_done at 0 because otherwise + # the move_line will not be deleted + package_move.move_line_ids.write({"qty_done": 0}) + package_move._do_unreserve() + package_move._recompute_state() + # Create an inventory at 0 in the move's source location + inventory.create_stock_issue(package_move, location, package, lot) + # Create a draft inventory to control stock + inventory.create_control_stock( + location, package_move.product_id, package, lot + ) + package_move._action_cancel() + # remove the package level (this is what does the `picking.do_unreserve()` + # method, but here we want to unreserve+unlink this package alone) + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) + + def stock_out_line(self, location_id, move_line_id): + """Declare a stock out on a move line + + It first ensures the stock.move only has this move line. If not, it + splits the move to have no side-effect on the other package levels/move + lines. + + It unreserves the move, create an inventory at 0 in the move's source + location, create a second draft inventory (if none exists) to check later. + Finally, it cancels the move. + + Transitions: + * start: no more content to move + * start_single: continue with the next package level / line + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) + inventory = self._actions_for("inventory") + move_line.move_id.split_other_move_lines(move_line) + move_line_src_location = move_line.location_id + move = move_line.move_id + package = move_line.package_id + lot = move_line.lot_id + # We need to set qty_done at 0 because otherwise + # the move_line will not be deleted + move_line.qty_done = 0 + move._do_unreserve() + move._recompute_state() + # Create an inventory at 0 in the move's source location + inventory.create_stock_issue(move, move_line_src_location, package, lot) + # Create a draft inventory to control stock + inventory.create_control_stock( + move_line_src_location, move.product_id, package, lot + ) + move._action_cancel() + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) + + def dismiss_package_level(self, location_id, package_level_id): + """Dismiss the package level. + + The result package of the related move lines is unset, then the package + level itself is removed from the picking. This allows to move parts + of the package to different locations. + + The user is then redirected to process the next line of the related picking. + + Transitions: + * start_single: continue with the next line + """ + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.record_not_found(), + ) + move_lines = package_level.move_line_ids + package_level.explode_package() + move_lines.write( + { + # ensure all the lines in the package are the next ones to be processed + "shopfloor_priority": 1, + } + ) + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=self.msg_store.package_open() + ) + + +class ShopfloorLocationContentTransferValidator(Component): + """Validators for the Location Content Transfer endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.location.content.transfer.validator" + _usage = "location_content_transfer.validator" + + def start_or_recover(self): + return {} + + def get_work(self): + return {} + + def cancel_work(self): + return {"location_id": {"required": True, "type": "integer"}} + + def scan_location(self): + return {"barcode": {"required": True, "type": "string"}} + + def set_destination_all(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + def go_to_single(self): + return {"location_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def scan_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def scan_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def set_destination_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + def set_destination_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "quantity": {"coerce": to_float, "required": True, "type": "float"}, + "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + def postpone_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def postpone_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def stock_out_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def stock_out_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def dismiss_package_level(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + +class ShopfloorLocationContentTransferValidatorResponse(Component): + """Validators for the Location Content Transfer endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.location.content.transfer.validator.response" + _usage = "location_content_transfer.validator.response" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "start": {}, + "scan_location": {}, + "get_work": {}, + "scan_destination_all": self._schema_all, + "start_single": self._schema_single, + "scan_destination": self._schema_single, + } + + @property + def _schema_all(self): + package_level_schema = self.schemas.package_level() + move_line_schema = self.schemas.move_line() + return { + "location": self.schemas._schema_dict_of(self.schemas.location()), + # we'll display all the packages and move lines *without package + # levels* + "package_levels": self.schemas._schema_list_of(package_level_schema), + "move_lines": self.schemas._schema_list_of(move_line_schema), + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, + } + + @property + def _schema_single(self): + schema_package_level = self.schemas.package_level() + schema_move_line = self.schemas.move_line() + return { + # we'll have one or the other... + "package_level": self.schemas._schema_dict_of(schema_package_level), + "move_line": self.schemas._schema_dict_of(schema_move_line), + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, + } + + def start_or_recover(self): + return self._response_schema( + next_states={ + "scan_location", + "scan_destination_all", + "start_single", + "get_work", + } + ) + + def scan_location(self): + return self._response_schema( + next_states={ + "scan_location", + "get_work", + "scan_destination_all", + "start_single", + } + ) + + def set_destination_all(self): + return self._response_schema( + next_states={"scan_location", "get_work", "scan_destination_all"} + ) + + def go_to_single(self): + return self._response_schema( + next_states={"scan_location", "get_work", "start_single"} + ) + + def scan_package(self): + return self._response_schema( + next_states={ + "scan_location", + "get_work", + "start_single", + "scan_destination", + } + ) + + def scan_line(self): + return self._response_schema( + next_states={ + "scan_location", + "get_work", + "start_single", + "scan_destination", + } + ) + + def set_destination_package(self): + return self._response_schema( + next_states={ + "scan_location", + "get_work", + "start_single", + "scan_destination", + } + ) + + def set_destination_line(self): + return self._response_schema( + next_states={ + "scan_location", + "get_work", + "start_single", + "scan_destination", + } + ) + + def postpone_package(self): + return self._response_schema( + next_states={"scan_location", "get_work", "start_single"} + ) + + def postpone_line(self): + return self._response_schema( + next_states={"scan_location", "get_work", "start_single"} + ) + + def stock_out_package(self): + return self._response_schema( + next_states={"scan_location", "get_work", "start_single"} + ) + + def stock_out_line(self): + return self._response_schema( + next_states={"scan_location", "get_work", "start_single"} + ) + + def dismiss_package_level(self): + return self._response_schema( + next_states={"scan_location", "get_work", "start_single"} + ) diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py new file mode 100644 index 0000000000..d2d90e4dba --- /dev/null +++ b/shopfloor/services/menu.py @@ -0,0 +1,60 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + + +class ShopfloorMenu(Component): + _inherit = "shopfloor.service.menu" + + def _convert_one_record(self, record): + values = super()._convert_one_record(record) + if record.picking_type_ids: + counters = self._get_move_line_counters(record) + values.update(counters) + return values + + def _get_move_line_counters(self, record): + """Lookup for all lines per menu item and compute counters.""" + # TODO: maybe to be improved w/ raw SQL as this run for each menu item + # and it's called every time the menu is opened/gets refreshed + move_line_search = self._actions_for( + "search_move_line", picking_types=record.picking_type_ids + ) + locations = record.picking_type_ids.mapped("default_location_src_id") + lines_per_menu = move_line_search.search_move_lines_by_location(locations) + return move_line_search.counters_for_lines(lines_per_menu) + + def _one_record_parser(self, record): + parser = super()._one_record_parser(record) + if not record.picking_type_ids: + return parser + return parser + [ + ("picking_type_ids:picking_types", ["id", "name"]), + ] + + +class ShopfloorMenuValidatorResponse(Component): + """Validators for the Menu endpoints responses""" + + _inherit = "shopfloor.service.menu.validator.response" + + @property + def _record_schema(self): + schema = super()._record_schema + schema.update( + { + "picking_types": self.schemas._schema_list_of( + self._picking_type_schema, required=False, nullable=True + ) + } + ) + schema.update(self.schemas.move_lines_counters()) + return schema + + @property + def _picking_type_schema(self): + return { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } diff --git a/shopfloor/services/picking_batch.py b/shopfloor/services/picking_batch.py new file mode 100644 index 0000000000..836efe4cbd --- /dev/null +++ b/shopfloor/services/picking_batch.py @@ -0,0 +1,126 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.osv import expression + +from odoo.addons.component.core import Component + + +class PickingBatch(Component): + """Picking Batch services for the client application.""" + + _inherit = "base.shopfloor.service" + _name = "shopfloor.picking.batch" + _usage = "picking_batch" + _expose_model = "stock.picking.batch" + _description = __doc__ + + def _get_base_search_domain(self): + base_domain = super()._get_base_search_domain() + user = self.env.user + return expression.AND( + [ + base_domain, + [ + "|", + "&", + ("user_id", "=", False), + ("state", "=", "draft"), + "&", + ("user_id", "=", user.id), + ("state", "in", ("draft", "in_progress")), + ], + ] + ) + + def _search(self, name_fragment=None, batch_ids=None): + domain = self._get_base_search_domain() + if name_fragment: + domain = expression.AND([domain, [("name", "ilike", name_fragment)]]) + if batch_ids: + domain = expression.AND([domain, [("id", "in", batch_ids)]]) + records = self.env[self._expose_model].search(domain, order="id asc") + records = records.filtered( + # Include done/cancel because we want to be able to work on the + # batch even if some pickings are done/canceled. They'll should be + # ignored later. + lambda batch: all( + ( + # When the batch is already in progress, we do not care + # about state of the pickings, because we want to be able + # to recover it in any case, even if, for instance, a stock + # error changed a picking to unavailable after the user + # started to work on the batch. + batch.state == "in_progress" + or picking.state in ("assigned", "done", "cancel") + ) + and picking.picking_type_id in self.picking_types + for picking in batch.picking_ids + ) + ) + return records + + def search(self, name_fragment=None): + """List available stock picking batches for current user + + Show only picking batches where all the pickings are available and + where all pickings are in the picking type of the current scenario. + """ + records = self._search(name_fragment=name_fragment) + return self._response( + data={"size": len(records), "records": self._to_json(records)} + ) + + def _convert_one_record(self, record): + assigned_pickings = record.picking_ids.filtered( + lambda picking: picking.state == "assigned" + ) + return { + "id": record.id, + "name": record.name, + "picking_count": len(assigned_pickings), + "move_line_count": len(assigned_pickings.mapped("move_line_ids")), + "weight": record.total_weight(), + } + + +class ShopfloorPickingBatchValidator(Component): + """Validators for the Picking_Batch endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.picking.batch.validator" + _usage = "picking_batch.validator" + + def search(self): + return { + "name_fragment": {"type": "string", "nullable": True, "required": False} + } + + +class ShopfloorPickingBatchValidatorResponse(Component): + """Validators for the Picking_Batch endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.picking.batch.validator.response" + _usage = "picking_batch.validator.response" + + def search(self): + return self._response_schema( + { + "size": {"required": True, "type": "integer"}, + "records": { + "type": "list", + "required": True, + "schema": {"type": "dict", "schema": self._record_schema}, + }, + } + ) + + @property + def _record_schema(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "picking_count": {"required": True, "type": "integer"}, + "move_line_count": {"required": True, "type": "integer"}, + "weight": {"required": True, "nullable": True, "type": "float"}, + } diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py new file mode 100644 index 0000000000..bf10f67907 --- /dev/null +++ b/shopfloor/services/service.py @@ -0,0 +1,101 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, exceptions + +from odoo.addons.component.core import AbstractComponent + + +class BaseShopfloorService(AbstractComponent): + """Base class for REST services""" + + _inherit = "base.shopfloor.service" + + @property + def search_move_line(self): + # TODO: propagating `picking_types` should probably be default + return self._actions_for("search_move_line", propagate_kwargs=["picking_types"]) + + +class BaseShopfloorProcess(AbstractComponent): + + _inherit = "base.shopfloor.process" + + def _get_process_picking_types(self): + """Return picking types for the menu""" + return self.work.menu.picking_type_ids + + @property + def picking_types(self): + if not hasattr(self.work, "picking_types"): + self.work.picking_types = self._get_process_picking_types() + if not self.work.picking_types: + raise exceptions.UserError( + _("No operation types configured on menu {}.").format( + self.work.menu.name + ) + ) + return self.work.picking_types + + @property + def search_move_line(self): + # TODO: picking types should be set somehow straight in the work context + # by `_validate_headers_update_work_context` in this way + # we can remove this override and the need to call `_get_process_picking_types` + # every time. + return self._actions_for("search_move_line", picking_types=self.picking_types) + + def _check_picking_status(self, pickings, states=("assigned",)): + """Check if given pickings can be processed. + + If the picking is already done, canceled or didn't belong to the + expected picking type, a message is returned. + """ + for picking in pickings: + if not picking.exists(): + return self.msg_store.stock_picking_not_found() + if picking.state == "done": + return self.msg_store.already_done() + if picking.state not in states: # the picking must be ready + return self.msg_store.stock_picking_not_available(picking) + if picking.picking_type_id not in self.picking_types: + return self.msg_store.cannot_move_something_in_picking_type() + + def is_src_location_valid(self, location): + """Check the source location is valid for given process. + + We ensure the source is valid regarding one of the picking types of the + process. + """ + return location.is_sublocation_of(self.picking_types.default_location_src_id) + + def is_dest_location_valid(self, moves, location): + """Check the destination location is valid for given moves. + + We ensure the destination is either valid regarding the picking + destination location or the move destination location. With the push + rules in the module stock_dynamic_routing in OCA/wms, it is possible + that the move destination is not anymore a child of the picking default + destination (as it is the last pushed move that now respects this + condition and not anymore this one that has a destination to an + intermediate location) + """ + return location.is_sublocation_of( + moves.picking_id.location_dest_id, func=all + ) or location.is_sublocation_of(moves.location_dest_id, func=all) + + def is_dest_location_to_confirm(self, location_dest_id, location): + """Check the destination location requires confirmation + + The location is valid but not the expected one: ask for confirmation + """ + return not location.is_sublocation_of(location_dest_id) + + def is_allow_move_create(self): + """Check a new operation can be created + + The menu is configured to allow the creation of moves + The menu is bind to one picking type + """ + return self.work.menu.allow_move_create and len(self.picking_types) == 1 diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py new file mode 100644 index 0000000000..f02af622bf --- /dev/null +++ b/shopfloor/services/single_pack_transfer.py @@ -0,0 +1,366 @@ +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) +# Copyright 2020 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://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 + + +class SinglePackTransfer(Component): + """Methods for the Single Pack Transfer Process + + You will find a sequence diagram describing states and endpoints + relationships [here](../docs/single_pack_transfer_diag_seq.png). + Keep [the sequence diagram](../docs/single_pack_transfer_diag_seq.plantuml) + up-to-date if you change endpoints. + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.single.pack.transfer" + _usage = "single_pack_transfer" + _description = __doc__ + + def _data_after_package_scanned(self, package_level): + move_lines = package_level.move_line_ids + package = package_level.package_id + # TODO use data.package_level (but the "name" moves in "package.name") + return { + "id": package_level.id, + "name": package.name, + "location_src": self.data.location(package.location_id), + "location_dest": self.data.location(package_level.location_dest_id), + "products": self.data.products(move_lines.product_id), + "picking": self.data.picking(move_lines.picking_id), + } + + def _response_for_start(self, message=None, popup=None): + return self._response(next_state="start", message=message, popup=popup) + + def _response_for_confirm_start(self, package_level, message=None): + data = self._data_after_package_scanned(package_level) + data["confirmation_required"] = True + return self._response( + next_state="start", + data=data, + message=message, + ) + + def _response_for_scan_location( + self, package_level, message=None, confirmation_required=False + ): + data = self._data_after_package_scanned(package_level) + data["confirmation_required"] = confirmation_required + return self._response( + next_state="scan_location", + data=data, + message=message, + ) + + def _scan_source(self, barcode, confirmation=False): + """Search a package""" + search = self._actions_for("search") + location = search.location_from_scan(barcode) + + package = self.env["stock.quant.package"] + if location: + package = self.env["stock.quant.package"].search( + [("location_id", "=", location.id)] + ) + if not package: + return (self.msg_store.no_pack_in_location(location), None) + if len(package) > 1: + return (self.msg_store.several_packs_in_location(location), None) + + if not package: + package = search.package_from_scan(barcode) + + if not package: + return (self.msg_store.package_not_found_for_barcode(barcode), None) + if not package.location_id: + return (self.msg_store.package_has_no_product_to_take(barcode), None) + if not self.is_src_location_valid(package.location_id): + return ( + self.msg_store.package_not_allowed_in_src_location( + barcode, self.picking_types + ), + None, + ) + + return (None, package) + + def start(self, barcode, confirmation=False): + picking_types = self.picking_types + message, package = self._scan_source(barcode, confirmation) + if message: + return self._response_for_start(message=message) + package_level = self.env["stock.package_level"].search( + [ + ("package_id", "=", package.id), + ("picking_id.picking_type_id", "in", picking_types.ids), + ] + ) + + # Start a savepoint because we are may unreserve moves of other + # picking types. If we do and we can't create a package level after, + # we rollback to the initial state + savepoint = self._actions_for("savepoint").new() + unreserved_moves = self.env["stock.move"].browse() + if not package_level: + other_move_lines = self.env["stock.move.line"].search( + [ + ("package_id", "=", package.id), + # to exclude canceled and done + ("state", "in", ("assigned", "partially_available")), + ] + ) + if any(line.qty_done > 0 for line in other_move_lines) or ( + other_move_lines and not self.work.menu.allow_unreserve_other_moves + ): + picking = fields.first(other_move_lines).picking_id + return self._response_for_start( + message=self.msg_store.package_already_picked_by(package, picking) + ) + elif other_move_lines and self.work.menu.allow_unreserve_other_moves: + + unreserved_moves = other_move_lines.move_id + other_package_levels = other_move_lines.package_level_id + other_package_levels.explode_package() + unreserved_moves._do_unreserve() + + # State is computed, can't use it in the domain. And it's probably faster + # to filter here rather than using a domain on "picking_id.state" that would + # use a sub-search on stock.picking: we shouldn't have dozens of package levels + # for a package. + package_level = package_level.filtered( + lambda pl: pl.state not in ("cancel", "done", "draft") + ) + message = self.msg_store.no_pending_operation_for_pack(package) + if not package_level and self.is_allow_move_create(): + package_level = self._create_package_level(package) + if not self.is_dest_location_valid( + package_level.move_line_ids.move_id, package_level.location_dest_id + ): + package_level = None + savepoint.rollback() + message = self.msg_store.package_unable_to_transfer(package) + + if not package_level: + # restore any unreserved move/package level + savepoint.rollback() + return self._response_for_start(message=message) + stock = self._actions_for("stock") + if self.work.menu.ignore_no_putaway_available and stock.no_putaway_available( + self.picking_types, package_level.move_line_ids + ): + # the putaway created a move line but no putaway was possible, so revert + # to the initial state + savepoint.rollback() + return self._response_for_start( + message=self.msg_store.no_putaway_destination_available() + ) + + if package_level.is_done and not confirmation: + return self._response_for_confirm_start( + package_level, message=self.msg_store.already_running_ask_confirmation() + ) + if not package_level.is_done: + package_level.is_done = True + + unreserved_moves._action_assign() + + savepoint.release() + + return self._response_for_scan_location(package_level) + + def _create_package_level(self, package): + # this method can be called only if we have one picking type + # (allow_move_create==True on menu) + assert self.picking_types.ensure_one() + StockPicking = self.env["stock.picking"].with_context( + default_picking_type_id=self.picking_types.id + ) + picking = StockPicking.create({}) + package_level = self.env["stock.package_level"].create( + { + "picking_id": picking.id, + "package_id": package.id, + "location_dest_id": picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + picking.action_confirm() + picking.action_assign() + # For packages that contain several products (so linked to several + # moves), the putaway destination computation of the strategy + # triggered by `action_assign()` above won't work, so we trigger + # the computation manually here at the package level. + package_level.recompute_pack_putaway() + return package_level + + def _is_move_state_valid(self, moves): + return all(move.state != "cancel" for move in moves) + + def validate(self, package_level_id, location_barcode, confirmation=False): + """Validate the transfer""" + search = self._actions_for("search") + + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + return self._response_for_start( + message=self.msg_store.operation_not_found() + ) + + # Do not use package_level.move_lines, this is only filled in when the + # moves have been created from a manually encoded package level, not + # when a package has been reserved for existing moves + moves = package_level.move_line_ids.move_id + if not self._is_move_state_valid(moves): + return self._response_for_start( + message=self.msg_store.operation_has_been_canceled_elsewhere() + ) + + scanned_location = search.location_from_scan(location_barcode) + if not scanned_location: + return self._response_for_scan_location( + package_level, message=self.msg_store.no_location_found() + ) + + if not self.is_dest_location_valid(moves, scanned_location): + return self._response_for_scan_location( + package_level, message=self.msg_store.dest_location_not_allowed() + ) + + if not confirmation and self.is_dest_location_to_confirm( + package_level.location_dest_id, scanned_location + ): + return self._response_for_scan_location( + package_level, + confirmation_required=True, + message=self.msg_store.confirm_location_changed( + package_level.location_dest_id, scanned_location + ), + ) + + self._set_destination_and_done(package_level, scanned_location) + return self._router_validate_success(package_level) + + def _is_last_move(self, move): + return move.picking_id.completion_info == "next_picking_ready" + + def _router_validate_success(self, package_level): + move = package_level.move_line_ids.move_id + + message = self.msg_store.confirm_pack_moved() + + completion_info_popup = None + if self._is_last_move(move): + completion_info = self._actions_for("completion.info") + completion_info_popup = completion_info.popup(package_level.move_line_ids) + return self._response_for_start(message=message, popup=completion_info_popup) + + def _set_destination_and_done(self, package_level, scanned_location): + # when writing the destination on the package level, it writes + # on the move lines + package_level.location_dest_id = scanned_location + stock = self._actions_for("stock") + stock.put_package_level_in_move(package_level) + stock.validate_moves(package_level.move_line_ids.move_id) + + def cancel(self, package_level_id): + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + return self._response_for_start( + message=self.msg_store.operation_not_found() + ) + # package.move_lines may be empty, it seems + moves = package_level.move_ids | package_level.move_line_ids.move_id + if "done" in moves.mapped("state"): + return self._response_for_start(message=self.msg_store.already_done()) + + package_level.is_done = False + return self._response_for_start( + message=self.msg_store.confirm_canceled_scan_next_pack() + ) + + +class SinglePackTransferValidator(Component): + """Validators for Single Pack Transfer methods""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.single.pack.transfer.validator" + _usage = "single_pack_transfer.validator" + + def start(self): + return { + "barcode": {"type": "string", "nullable": False, "required": True}, + "confirmation": {"type": "boolean", "required": False}, + } + + def cancel(self): + return { + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"} + } + + def validate(self): + return { + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_barcode": {"type": "string", "nullable": False, "required": True}, + "confirmation": {"type": "boolean", "required": False}, + } + + +class SinglePackTransferValidatorResponse(Component): + """Validators for Single Pack Transfer methods responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.single.pack.transfer.validator.response" + _usage = "single_pack_transfer.validator.response" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + schema_for_start = self._schema_for_package_level_details() + schema_for_start.update(self._schema_confirmation_required()) + schema_for_scan_location = self._schema_for_package_level_details(required=True) + schema_for_scan_location.update(self._schema_confirmation_required()) + return { + "start": schema_for_start, + "scan_location": schema_for_scan_location, + } + + def start(self): + return self._response_schema(next_states={"start", "scan_location"}) + + def cancel(self): + return self._response_schema(next_states={"start"}) + + def validate(self): + return self._response_schema(next_states={"scan_location", "start"}) + + def _schema_for_package_level_details(self, required=False): + # TODO use schemas.package_level (but the "name" moves in "package.name") + return { + "id": {"required": required, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": required}, + "location_src": {"type": "dict", "schema": self.schemas.location()}, + "location_dest": {"type": "dict", "schema": self.schemas.location()}, + "products": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.product()}, + }, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } + + def _schema_confirmation_required(self): + return { + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, + } diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py new file mode 100644 index 0000000000..b90c7fcb4a --- /dev/null +++ b/shopfloor/services/zone_picking.py @@ -0,0 +1,1938 @@ +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import functools +from collections import defaultdict + +from odoo.fields import first +from odoo.tools.float_utils import float_compare, float_is_zero + +from odoo.addons.base_rest.components.service import to_bool, to_int +from odoo.addons.component.core import Component + +from ..exceptions import ConcurentWorkOnTransfer +from ..utils import to_float + + +class ZonePicking(Component): + """ + Methods for the Zone Picking Process + + Zone picking of move lines. + + You will find a sequence diagram describing states and endpoints + relationships [here](../docs/zone_picking_diag_seq.png). + Keep [the sequence diagram](../docs/zone_picking_diag_seq.plantuml) + up-to-date if you change endpoints. + + Note: + + * Several operation types could be linked to a single menu item + * If several operator work in a same zone, they’ll see the same move lines but + will only posts theirs when unloading their goods, which means that when they + scan lines, the backend has to store the user id on the move lines + + Workflow: + + 1. The operator scans the zone location with goods to pick (zone location + meaning a parent location, not a leaf) + 2. If the zone contains lines from different picking types, the operator + chooses the type to work with + 3. The client application shows the list of move lines, with an option + to choose the sorting of the lines + 4. The operator selects a line to pick, by scanning one of: + + * location, if only a single move line there; if a location is scanned + and it contains several move lines, the view is updated to show only + them. The next scan (e.g. a product) will be based on the previous + scanned location. + * package, if it is linked to a move line. If the package is not linked + to an existing move line but can be a replacement for one, the view is + updated to show only the fitting move lines. And the user can confirm + the change of package by scanning it a second time. + * product, if only a single move line matches. Otherwise the view is updated + to show only the matching move lines, The next scan (e.g. a location) will + be based on the previous product scanned. + * lot + + 5. The operator scans the destination for the line they scanned, this is where + the path splits: + + * they scan a location, in which case the move line's destination is + updated with it and the move is done + * they scan a package, which becomes the destination package of the move + line, the move line is not set to done, its ``qty_done`` is updated + and a field ``shopfloor_user_id`` is set to the user; consider the + move line is set in a buffer + + 6. At any point, from the list of moves, the operator can reach the + "unload" screens to unload what they had put into the buffer (scanned a + destination package during step 5.). This is optional as they can directly + move whole pallets by scanning the destination in step 5. + 7. The unload screens (similar to those of the Cluster Picking workflow) are + used to move what has been put in the buffer: + + * if the original destination of all the lines is unique, screen allows + to scan a single destination; they can use a "split" button to go to + the line by line screen + * if the lines have different destinations, they have to scan the destination + package, then scan the destination location, scan the next package and its + destination and so on. + + The list of move lines (point 4.) has support functions: + + * Change a lot or pack: if the expected lot is at the very bottom of the + location or a stock error forces a user to change lot or pack, user can + do it during the picking. + * Declare stock out: if a good is in fact not in stock or only partially. + Note the move lines may become unavailable or partially unavailable and + generate a back-order. + + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.zone.picking" + _usage = "zone_picking" + _description = __doc__ + + @property + def _validation_rules(self): + return super()._validation_rules + ( + # rule to apply, active flag handler + (self.ZONE_LOCATION_ID_HEADER_RULE, self._requires_header_zone_picking), + (self.PICKING_TYPE_ID_HEADER_RULE, self._requires_header_zone_picking), + (self.LINES_ORDER_HEADER_RULE, self._requires_header_zone_picking), + ) + + def _requires_header_zone_picking(self, request, method): + # TODO: maybe we should have a decorator? + return method not in ("select_zone", "scan_location") + + ZONE_LOCATION_ID_HEADER_RULE = ( + # header name, coerce func, ctx handler, mandatory + "HTTP_SERVICE_CTX_ZONE_LOCATION_ID", + int, + "_work_ctx_get_zone_location_id", + True, + ) + PICKING_TYPE_ID_HEADER_RULE = ( + # header name, coerce func, ctx handler, mandatory + "HTTP_SERVICE_CTX_PICKING_TYPE_ID", + int, + "_work_ctx_get_picking_type_id", + True, + ) + LINES_ORDER_HEADER_RULE = ( + # header name, coerce func, ctx handler, mandatory + "HTTP_SERVICE_CTX_LINES_ORDER", + str, + "_work_ctx_get_lines_order", + True, + ) + + def _work_ctx_get_zone_location_id(self, rec_id): + return ( + "current_zone_location", + self.env["stock.location"].browse(rec_id).exists(), + ) + + def _work_ctx_get_picking_type_id(self, rec_id): + return ( + "current_picking_type", + self.env["stock.picking.type"].browse(rec_id).exists(), + ) + + def _work_ctx_get_lines_order(self, order): + return "current_lines_order", order + + @property + def zone_location(self): + return self.work.current_zone_location + + @property + def picking_type(self): + return getattr(self.work, "current_picking_type", None) + + @property + def lines_order(self): + return getattr(self.work, "current_lines_order", "priority") + + def _pick_pack_same_time(self): + return self.work.menu.pick_pack_same_time + + def _response_for_start(self, message=None): + zones = self.work.menu.picking_type_ids.mapped( + "default_location_src_id.child_ids" + ) + data = {"zones": self._data_for_select_zone(zones)} + buffer = self._find_buffer_move_lines() + if buffer: + # Some lines can be unloaded, let the user know + # The call to the endpoint will need the location and picking id + line = first(buffer) + picking_type = line.picking_id.picking_type_id + zone = line.move_id.location_id + data["buffer"] = { + "zone_location": self.data.location(zone), + "picking_type": self.data.picking_type(picking_type), + } + return self._response( + next_state="start", + data=data, + message=message, + ) + + def _response_for_select_picking_type( + self, zone_location, picking_types, message=None + ): + return self._response( + next_state="select_picking_type", + data=self._data_for_select_picking_type(zone_location, picking_types), + message=message, + ) + + def _response_for_select_line( + self, + move_lines, + message=None, + popup=None, + confirmation_required=False, + product=False, + sublocation=False, + package=False, + ): + if confirmation_required and not message: + message = self.msg_store.need_confirmation() + data = self._data_for_move_lines( + move_lines, product=product, sublocation=sublocation, package=package + ) + data["confirmation_required"] = confirmation_required + data["scan_location_or_pack_first"] = self.work.menu.scan_location_or_pack_first + return self._response( + next_state="select_line", + data=data, + message=message, + popup=popup, + ) + + def _response_for_set_line_destination( + self, + move_line, + message=None, + confirmation_required=False, + **kw, + ): + if confirmation_required and not message: + message = self.msg_store.need_confirmation() + data = self._data_for_move_line(move_line) + data["move_line"].update(kw) + data["confirmation_required"] = confirmation_required + return self._response( + next_state="set_line_destination", data=data, message=message + ) + + def _response_for_zero_check(self, move_line, message=None): + data = self._data_for_location(move_line.location_id) + data["move_line"] = self.data.move_line(move_line) + return self._response( + next_state="zero_check", + data=data, + message=message, + ) + + def _response_for_change_pack_lot(self, move_line, message=None): + return self._response( + next_state="change_pack_lot", + data=self._data_for_move_line(move_line), + message=message, + ) + + def _response_for_unload_all( + self, + move_lines, + message=None, + confirmation_required=False, + ): + if confirmation_required and not message: + message = self.msg_store.need_confirmation() + data = self._data_for_move_lines(move_lines) + data["confirmation_required"] = confirmation_required + return self._response(next_state="unload_all", data=data, message=message) + + def _response_for_unload_single(self, move_line, message=None, popup=None): + buffer_lines = self._find_buffer_move_lines() + completion_info = self._actions_for("completion.info") + completion_info_popup = completion_info.popup(buffer_lines) + return self._response( + next_state="unload_single", + data=self._data_for_move_line(move_line), + message=message, + popup=popup or completion_info_popup, + ) + + def _response_for_unload_set_destination( + self, + move_line, + message=None, + confirmation_required=False, + ): + if confirmation_required and not message: + message = self.msg_store.need_confirmation() + data = self._data_for_move_line(move_line) + data["confirmation_required"] = confirmation_required + return self._response( + next_state="unload_set_destination", data=data, message=message + ) + + def _data_for_select_picking_type(self, zone_location, picking_types): + data = { + "zone_location": self.data.location(zone_location), + # available picking types to choose from + "picking_types": self.data.picking_types(picking_types), + } + for datum in data["picking_types"]: + picking_type = self.env["stock.picking.type"].browse(datum["id"]) + zone_lines = self._picking_type_zone_lines(zone_location, picking_type) + counters = self._counters_for_zone_lines(zone_lines) + datum.update(counters) + return data + + def _counters_for_zone_lines(self, zone_lines): + return self.search_move_line.counters_for_lines(zone_lines) + + def _picking_type_zone_lines(self, zone_location, picking_type): + return self.search_move_line.search_move_lines_by_location( + zone_location, picking_type=picking_type + ) + + def _data_for_move_line( + self, move_line, zone_location=None, picking_type=None, **kw + ): + zone_location = zone_location or self.zone_location + picking_type = picking_type or self.picking_type + line_data = self.data.move_line(move_line, with_picking=True) + line_data.update(kw) + return { + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_line": line_data, + } + + def _data_for_move_lines( + self, + move_lines, + zone_location=None, + picking_type=None, + product=None, + sublocation=None, + package=None, + ): + zone_location = zone_location or self.zone_location + picking_type = picking_type or self.picking_type + data = { + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_lines": self.data.move_lines(move_lines, with_picking=True), + } + if product: + data["product"] = self.data.product(product) + if sublocation and sublocation != zone_location: + data["sublocation"] = self.data.location(sublocation) + if package: + data["package"] = self.data.package(package) + for data_move_line in data["move_lines"]: + # TODO: this could be expensive, think about a better way + # to retrieve if location will be empty. + # Maybe group lines by location and compute only once. + move_line = self.env["stock.move.line"].browse(data_move_line["id"]) + # `location_will_be_empty` flag states if, by processing this move line + # and picking the product, the location will be emptied. + data_move_line[ + "location_will_be_empty" + ] = move_line.location_id.planned_qty_in_location_is_empty(move_line) + return data + + def _data_for_location(self, location, zone_location=None, picking_type=None): + zone_location = zone_location or self.zone_location + picking_type = picking_type or self.picking_type + return { + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "location": self.data.location(location), + } + + def _zone_lines(self, zones): + return self._find_location_move_lines(zones) + + def _data_for_select_zone(self, zones): + """Retrieve detailed info for each zone. + + Zone without lines are skipped. + Zone with lines will have line counters by operation type. + + :param zones: zone location recordset + :return: see _schema_for_select_zone + """ + res = [] + for zone in zones: + zone_data = self.data.location(zone) + zone_lines = self._zone_lines(zone) + if not zone_lines: + continue + lines_by_op_type = defaultdict(list) + for line in zone_lines: + lines_by_op_type[line.picking_id.picking_type_id].append(line) + + zone_data["operation_types"] = [] + zone_counters = defaultdict(int) + for picking_type, lines in lines_by_op_type.items(): + op_type_data = self.data.picking_type(picking_type) + counters = self._counters_for_zone_lines(lines) + op_type_data.update(counters) + zone_data["operation_types"].append(op_type_data) + for k, v in counters.items(): + zone_counters[k] += v + zone_data.update(zone_counters) + res.append(zone_data) + return res + + def _find_location_move_lines( + self, + locations=None, + picking_type=None, + package=None, + product=None, + lot=None, + match_user=False, + enforce_empty_package=False, + ): + """Find lines that potentially need work in given locations.""" + return self.search_move_line.search_move_lines_by_location( + locations or self.zone_location, + picking_type=picking_type or self.picking_type, + order=self.lines_order, + package=package, + product=product, + lot=lot, + match_user=match_user, + enforce_empty_package=enforce_empty_package, + ) + + def _find_buffer_move_lines_domain(self, dest_package=None): + domain = [ + ("picking_id.picking_type_id", "in", self.picking_types.ids), + ("qty_done", ">", 0), + ("state", "not in", ("cancel", "done")), + ("result_package_id", "!=", False), + ("shopfloor_user_id", "=", self.env.user.id), + ] + if dest_package: + domain.append(("result_package_id", "=", dest_package.id)) + return domain + + def _find_buffer_move_lines(self, dest_package=None): + """Find lines that belongs to the operator's buffer and return them + grouped by destination package. + """ + domain = self._find_buffer_move_lines_domain(dest_package) + return ( + self.env["stock.move.line"] + .search(domain) + .sorted(self.search_move_line._sort_key_move_lines(self.lines_order)) + ) + + def _group_buffer_move_lines_by_package(self, move_lines): + data = {} + for move_line in move_lines: + data.setdefault(move_line.result_package_id, move_line.browse()) + data[move_line.result_package_id] |= move_line + return data + + def select_zone(self): + """Retrieve all available zones to work with. + + A zone is defined by the first level location below the source location + of the operation types linked to the menu. + + The count of lines to process by available operations is computed per each zone. + """ + return self._response_for_start() + + def scan_location(self, barcode): + """Scan the zone location where the picking should occur + + The location must be a sub-location of one of the picking types' + default source locations of the menu. + + Transitions: + * start: invalid barcode + * select_picking_type: the location is valid, user has to choose a picking type + """ + search = self._actions_for("search") + zone_location = search.location_from_scan(barcode) + if not zone_location: + return self._response_for_start(message=self.msg_store.no_location_found()) + if not self.is_src_location_valid(zone_location): + return self._response_for_start( + message=self.msg_store.location_not_allowed() + ) + move_lines = self._find_location_move_lines(zone_location) + if not move_lines: + return self._response_for_start( + message=self.msg_store.no_lines_to_process() + ) + picking_types = move_lines.picking_id.picking_type_id + return self._response_for_select_picking_type(zone_location, picking_types) + + def list_move_lines(self): + """List all move lines to pick, sorted + + Transitions: + * select_line: show the list of move lines + """ + return self._list_move_lines(self.zone_location) + + def _list_move_lines( + self, location, product=False, sublocation=False, package=False + ): + move_lines = self._find_location_move_lines( + sublocation or location, product=product, package=package, match_user=True + ) + return self._response_for_select_line( + move_lines, product=product, sublocation=sublocation, package=package + ) + + def _scan_source_location( + self, + barcode, + confirmation=False, + product_id=False, + sublocation=False, + package=False, + ): + """Search a location and find available lines into it.""" + response = None + message = None + search = self._actions_for("search") + location = search.location_from_scan(barcode) + if not location: + return response, message + + if not location.is_sublocation_of(self.zone_location): + response = self._list_move_lines(self.zone_location) + message = self.msg_store.location_not_allowed() + return response, message + + if package and package.location_id != location: + # Do not search based on a package from a previous location + package = False + product, lot, packages = self._find_product_in_location( + location, product_id, package + ) + if len(packages) > 1: + message = self.msg_store.several_packs_in_location(location) + elif len(packages) == 1 and self.work.menu.scan_location_or_pack_first: + message = self.msg_store.scan_the_package() + elif len(product) > 1 and not message: + message = self.msg_store.several_products_in_location(location) + elif len(lot) > 1 and not message: + message = self.msg_store.several_lots_in_location(location) + if message: + response = self._list_move_lines( + location, sublocation=location, package=package + ) + return response, message + move_lines = self._find_location_move_lines( + location, + product=product, + lot=lot, + package=package, + match_user=True, + ) + if move_lines: + move_line = first(move_lines) + response = self._response_for_set_line_destination( + move_line, qty_done=self._get_prefill_qty(move_line) + ) + else: + response = self._list_move_lines(self.zone_location) + message = self.msg_store.wrong_record(location) + return response, message + + def _find_product_in_location(self, location, product_id, package=False): + """Find the prooducts in stock in given location move line in the location.""" + domain = [("location_id", "=", location.id)] + if product_id: + domain.append(("product_id", "=", product_id)) + if package: + domain.append(("package_id", "=", package.id)) + quants = self.env["stock.quant"].search(domain) + product = quants.product_id + lot = quants.lot_id + package = quants.package_id + return product, lot, package + + def _scan_source_package( + self, + barcode, + confirmation=False, + product_id=False, + sublocation=False, + package=False, + ): + """Search a package and find available lines for it. + + First search for lines that have the specific package. + If none are found search for lines whose package could be replaced + by the one selected and in that case ask for confirmation. + """ + message = None + response = None + search = self._actions_for("search") + packaging = self._actions_for("packaging") + package = search.package_from_scan(barcode) + if not package: + return response, message + if not package.location_id.is_sublocation_of(self.zone_location): + # Package is not in an allowed location + response = self._list_move_lines(self.zone_location) + message = self.msg_store.location_not_allowed() + return response, message + + move_lines = self._find_location_move_lines( + locations=sublocation, package=package + ) + if move_lines: + if packaging.package_has_several_products(package): + message = self.msg_store.several_products_in_package(package) + if packaging.package_has_several_lots(package): + message = self.msg_store.several_lots_in_package(package) + if message: + return ( + self._list_move_lines( + self.zone_location, + sublocation=sublocation, + package=package, + ), + message, + ) + move_line = first(move_lines) + # Fix me for a package prefill qty is zero ? + qty_done = self._get_prefill_qty(move_line) + response = self._response_for_set_line_destination( + move_line, qty_done=qty_done + ) + return response, message + # Check if the package selected can be a substitute on a move line + products = package.quant_ids.filtered(lambda q: q.quantity > 0).product_id + for product in products: + move_lines |= self._find_location_move_lines( + locations=package.location_id, + product=product, + ) + if move_lines: + if not confirmation: + message = self.msg_store.package_different_change() + response = self._response_for_select_line( + move_lines, message, confirmation_required=True + ) + else: + change_package_lot = self._actions_for("change.package.lot") + response = change_package_lot.change_package( + first(move_lines), + package, + # FIXME we may need to pass the quantity being done + self._response_for_set_line_destination, + self._response_for_change_pack_lot, + ) + else: + response = self._list_move_lines(sublocation or self.zone_location) + message = self.msg_store.package_has_no_product_to_take(barcode) + return response, message + + def _get_prefill_qty(self, move_line, qty=0): + """Returns the done quantity to use on the selection of a move line. + + Before the introduction of the no prefill quantity parameter on scenarios, + when a move line was selected the done quantity was equal to the quantity + on the line. This is still the default behaviour. + But when the no prefill quantity is set. The quantity done will be set + according to the scanned barcode. + + """ + if self.work.menu.no_prefill_qty: + return qty + return move_line.reserved_uom_qty + + def _scan_source_product( + self, + barcode, + confirmation=False, + product_id=False, + sublocation=False, + package=False, + ): + """Search a product and find available lines for it.""" + message = None + response = None + search = self._actions_for("search") + product = search.product_from_scan(barcode) + packaging = self.env["product.packaging"].browse() + if not product: + packaging = search.packaging_from_scan(barcode) + product = packaging.product_id + if not product: + return response, message + move_lines = self._find_location_move_lines( + locations=sublocation, + product=product, + package=package, + enforce_empty_package=self.work.menu.scan_location_or_pack_first, + ) + + move_lines_with_package_ids = [] + move_lines_without_package_ids = [] + if not package and self.work.menu.scan_location_or_pack_first: + for move_line in move_lines: + if move_line.package_id: + move_lines_with_package_ids.append(move_line.id) + else: + move_lines_without_package_ids.append(move_line.id) + move_lines = move_lines.browse(move_lines_without_package_ids) + + if len(move_lines.location_id) > 1: + message = self.msg_store.several_move_in_different_location() + elif len(move_lines.lot_id) > 1: + message = self.msg_store.several_move_with_different_lot() + if message: + response = self._list_move_lines( + self.zone_location, product, sublocation=sublocation, package=package + ) + elif move_lines: + move_line = first(move_lines) + qty_done = self._get_prefill_qty(move_line, qty=(packaging.qty or 1.0)) + response = self._response_for_set_line_destination( + move_line, qty_done=qty_done + ) + else: + response = self._list_move_lines( + sublocation or self.zone_location, + sublocation=sublocation, + package=package, + ) + if move_lines_with_package_ids: + message = self.msg_store.product_not_unitary_in_package_scan_package() + else: + message = self.msg_store.product_not_found_in_pickings() + return response, message + + def _scan_source_lot( + self, + barcode, + confirmation=False, + product_id=False, + sublocation=False, + package=False, + ): + """Search a lot and find available lines for it.""" + message = None + response = None + search = self._actions_for("search") + products = self.env["product.product"].browse(product_id) + # Could get several lots from different products, check each of them + lots = search.lot_from_scan(barcode, products=products, limit=None) + if not lots: + return response, message + move_lines_with_package_ids = [] + move_lines_without_package_ids = [] + for lot in lots: + move_lines = self._find_location_move_lines( + locations=sublocation, lot=lot, package=package + ) + if not move_lines: + continue + if not package and self.work.menu.scan_location_or_pack_first: + for move_line in move_lines: + if move_line.package_id: + move_lines_with_package_ids.append(move_line.id) + else: + move_lines_without_package_ids.append(move_line.id) + move_lines = move_lines.browse(move_lines_without_package_ids) + + if len(move_lines.location_id) > 1: + message = self.msg_store.several_move_in_different_location() + response = self.list_move_lines() + else: + move_line = first(move_lines) + qty_done = self._get_prefill_qty(move_line, qty=1.0) + response = self._response_for_set_line_destination( + move_line, qty_done=qty_done + ) + return response, message + message = self.msg_store.lot_not_found_in_pickings() + response = self._list_move_lines( + sublocation or self.zone_location, package=package + ) + if move_lines_with_package_ids: + message = self.msg_store.lot_mixed_package_scan_package() + else: + message = self.msg_store.lot_not_found_in_pickings() + return response, message + + def scan_source( + self, + barcode, + confirmation=False, + product_id=None, + sublocation_id=None, + package_id=None, + ): + """Select a move line or narrow the list of move lines + + When the barcode is a location and we can unambiguously know which move + line is picked (the quants in the location has one product/lot/package, + matching a single move line), then the move line is selected. + Otherwise, the list of move lines is refreshed with a filter on the + scanned location, showing the move lines that have this location as + source. + + When the barcode is a package, a product or a lot, the first matching + line is selected. + + A selected line goes to the next screen to select the destination + location or package. + + If a product is passed to the function the search on move line will + be filtered based on it as well. + + And if a sublocation_id is passed the search on move line will be restriced + to it. + + Transitions: + * select_line: barcode not found or narrow the list on a location + * set_line_destination: a line has been selected for picking + """ + # select corresponding move line from barcode (location, package, product, lot) + sublocation = ( + self.env["stock.location"].browse(sublocation_id).exists() + if sublocation_id + else self.env["stock.location"] + ) + selected_package = ( + self.env["stock.quant.package"].browse(package_id).exists() + if package_id + else self.env["stock.quant.package"] + ) + handlers = ( + # search by location 1st + self._scan_source_location, + # then by package + self._scan_source_package, + ) + ( + # if first scan location or pack option is not set + # or the sublocation has already been scanned + ( + # by product + self._scan_source_product, + # then by lot + self._scan_source_lot, + ) + if not self.work.menu.scan_location_or_pack_first + or sublocation_id + or selected_package + else () + ) + for handler in handlers: + response, message = handler( + barcode, + confirmation=confirmation, + product_id=product_id, + sublocation=sublocation, + package=selected_package, + ) + if response: + return self._response(base_response=response, message=message) + response = self._list_move_lines( + self.zone_location, sublocation=sublocation, package=selected_package + ) + return self._response( + base_response=response, message=self.msg_store.barcode_not_found() + ) + + def _set_destination_location(self, move_line, quantity, confirmation, location): + location_changed = False + response = None + + # A valid location is a sub-location of the original destination, or a + # any sub-location of the picking type's default destination location + # if `confirmation is True + # Ask confirmation to the user if the scanned location is not in the + # expected ones but is valid (in picking type's default destination) + if not self.is_dest_location_valid(move_line.move_id, location): + response = self._response_for_set_line_destination( + move_line, + message=self.msg_store.dest_location_not_allowed(), + qty_done=quantity, + ) + return (location_changed, response) + + if not confirmation and self.is_dest_location_to_confirm( + move_line.location_dest_id, location + ): + response = self._response_for_set_line_destination( + move_line, + message=self.msg_store.confirm_location_changed( + move_line.location_dest_id, location + ), + confirmation_required=True, + qty_done=quantity, + ) + return (location_changed, response) + + # If no destination package + if not move_line.result_package_id: + response = self._response_for_set_line_destination( + move_line, + message=self.msg_store.dest_package_required(), + qty_done=quantity, + ) + return (location_changed, response) + # destination location set to the scanned one + self._write_destination_on_lines(move_line, location) + stock = self._actions_for("stock") + try: + stock.mark_move_line_as_picked(move_line, quantity, check_user=True) + except ConcurentWorkOnTransfer as error: + response = self._response_for_set_line_destination( + move_line, + message={ + "message_type": "error", + "body": str(error), + }, + qty_done=quantity, + ) + return (location_changed, response) + stock.validate_moves(move_line.move_id) + location_changed = True + # Zero check + zero_check = self.picking_type.shopfloor_zero_check + if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): + response = self._response_for_zero_check(move_line) + return (location_changed, response) + + def _is_package_empty(self, package): + return not bool(package.quant_ids) + + def _is_package_already_used(self, package): + # Deprecated, use planned_move_line_ids instead + return bool(package.planned_move_line_ids) + + def _move_line_compare_qty(self, move_line, qty): + rounding = move_line.product_uom_id.rounding + return float_compare( + qty, move_line.reserved_uom_qty, precision_rounding=rounding + ) + + def _move_line_full_qty(self, move_line, qty): + rounding = move_line.product_uom_id.rounding + return float_is_zero( + move_line.reserved_uom_qty - qty, precision_rounding=rounding + ) + + def _set_destination_package(self, move_line, quantity, package): + package_changed = False + response = None + # A valid package is: + # * an empty package + # * not used as destination for another move line + if not self._is_package_empty(package): + response = self._response_for_set_line_destination( + move_line, + message=self.msg_store.package_not_empty(package), + qty_done=quantity, + ) + return (package_changed, response) + multiple_move_allowed = self.work.menu.multiple_move_single_pack + if package.planned_move_line_ids and not multiple_move_allowed: + response = self._response_for_set_line_destination( + move_line, + message=self.msg_store.package_already_used(package), + qty_done=quantity, + ) + return (package_changed, response) + # the quantity done is set to the passed quantity + # but if we move a partial qty, we need to split the move line + compare = self._move_line_compare_qty(move_line, quantity) + qty_greater = compare == 1 + if qty_greater: + response = self._response_for_set_line_destination( + move_line, + message=self.msg_store.unable_to_pick_more(move_line.reserved_uom_qty), + qty_done=quantity, + ) + return (package_changed, response) + stock = self._actions_for("stock") + try: + stock.mark_move_line_as_picked( + move_line, quantity, package, check_user=True + ) + except ConcurentWorkOnTransfer as error: + response = self._response_for_set_line_destination( + move_line, + message={ + "message_type": "error", + "body": str(error), + }, + qty_done=quantity, + ) + return (package_changed, response) + package_changed = True + # Zero check + zero_check = self.picking_type.shopfloor_zero_check + if zero_check and move_line.location_id.planned_qty_in_location_is_empty(): + response = self._response_for_zero_check(move_line) + return (package_changed, response) + + def _set_destination_update_quantity(self, move_line, quantity, barcode): + """Handle the done quantity increment on set_destination end point.""" + response = None + if not self.work.menu.no_prefill_qty: + return response + search = self._actions_for("search") + # Handle barcode of product or packaging + product = search.product_from_scan(barcode) + packaging = self.env["product.packaging"].browse() + if not product: + packaging = search.packaging_from_scan(barcode) + product = packaging.product_id + if product and move_line.product_id == product: + quantity += packaging.qty or 1.0 + response = self._response_for_set_line_destination( + move_line, qty_done=quantity + ) + return response + # Handle barcode of a lot + lot = search.lot_from_scan(barcode) + if lot and move_line.lot_id == lot: + quantity += 1.0 + response = self._response_for_set_line_destination( + move_line, qty_done=quantity + ) + return response + return response + + # flake8: noqa: C901 + def set_destination( + self, + move_line_id, + barcode, + quantity, + confirmation=False, + ): + """Set a destination location (and done) or a destination package (in buffer) + + When a line is picked, it can either: + + * be moved directly to a destination location, typically a pallet + * be moved to a destination package, that we'll call buffer in the docstrings + + When the barcode is a valid location, actions on the move line: + + * destination location set to the scanned one + * the quantity done is set to the passed quantity + * if the move has other move lines, it is split to have only this move line + * set to done (without backorder) + + A valid location is a sub-location of the original destination, or a + sub-location of the picking type's default destination location if + ``confirmation`` is True. + + When the barcode is a valid package, actions on the move line: + + * destination package is set to the scanned one + * the quantity done is set to the passed quantity + * the field ``shopfloor_user_id`` is updated with the current user + + Those fields will be used to identify which move lines are in the buffer. + + A valid package is: + + * an empty package + * not used as destination for another move line + + With the addition of the no prefill quantity parameter this endpoint can also + be used to change the done quantity on the move line before setting a + destination. + + When the barcode is the product (or its packaging) or the lot on the line: + * The done quantity is incremented by one or the packaging quantity. + + Transitions: + * select_line: destination has been set, showing the next lines to pick + * zero_check: if the option is active and if the quantity of product + moved is 0 in the source location after the move (beware: at this + point the product we put in the buffer is still considered to be in + the source location, so we have to compute the source location's + quantity - qty_done). + * set_line_destination: the scanned location is invalid, user has to + scan another one + * set_line_destination+confirmation_required: the scanned location is not + in the expected one but is valid (in picking type's default destination) + """ + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + + pkg_moved = False + search = self._actions_for("search") + accept_only_package = not self._move_line_full_qty(move_line, quantity) + + response = self._set_destination_update_quantity(move_line, quantity, barcode) + if response: + return response + + if quantity <= 0: + message = self.msg_store.picking_zero_quantity() + return self._response_for_set_line_destination( + move_line, + message=message, + qty_done=self._get_prefill_qty(move_line, qty=0), + ) + + extra_message = "" + if not accept_only_package: + # When the barcode is a location + location = search.location_from_scan(barcode) + if location: + if self._pick_pack_same_time(): + ( + good_for_packing, + message, + ) = self._handle_pick_pack_same_time_for_location(move_line) + # TODO: we should append the msg instead. + # To achieve this, we should refactor `response.message` to a list + # or, to no break backward compat, we could add `extra_messages` + # to allow backend to send a main message and N additional messages. + extra_message = message + if not good_for_packing: + return self._response_for_set_line_destination( + move_line, message=message, qty_done=quantity + ) + pkg_moved, response = self._set_destination_location( + move_line, + quantity, + confirmation, + location, + ) + if response: + if extra_message: + if response.get("message"): + response["message"]["body"] += "\n" + extra_message["body"] + else: + response["message"] = extra_message + return response + + # When the barcode is a package + package = search.package_from_scan(barcode) + if package: + if self._pick_pack_same_time(): + ( + good_for_packing, + message, + ) = self._handle_pick_pack_same_time_for_package(move_line, package) + if not good_for_packing: + return self._response_for_set_line_destination( + move_line, message=message, qty_done=quantity + ) + location = move_line.location_dest_id + pkg_moved, response = self._set_destination_package( + move_line, quantity, package + ) + if response: + return response + + message = None + + if not pkg_moved and not package: + if accept_only_package: + message = self.msg_store.package_not_found_for_barcode(barcode) + else: + # we don't know if user wanted to scan a location or a package + message = self.msg_store.barcode_not_found() + return self._response_for_set_line_destination( + move_line, message=message, qty_done=quantity + ) + + if pkg_moved: + message = self.msg_store.confirm_pack_moved() + if extra_message: + message["body"] += "\n" + extra_message["body"] + + # Process the next line + response = self.list_move_lines() + return self._response(base_response=response, message=message) + + def _handle_pick_pack_same_time_for_location(self, move_line): + """Automatically put product in carrier-specific package. + + :param move_line: current move line to process + :return: tuple like ($succes_flag, $success_or_failure_message) + """ + good_for_packing = False + message = "" + picking = move_line.picking_id + carrier = picking.ship_carrier_id or picking.carrier_id + if carrier: + actions = self._actions_for("packaging") + pkg = actions.create_delivery_package(carrier) + move_line.write({"result_package_id": pkg.id}) + message = self.msg_store.goods_packed_in(pkg) + good_for_packing = True + else: + message = self.msg_store.picking_without_carrier_cannot_pack(picking) + return good_for_packing, message + + def _handle_pick_pack_same_time_for_package(self, move_line, package): + """Validate package for packing at the same time. + + :param move_line: current move line to process + :param package: package to validate + :return: tuple like ($succes_flag, $success_or_failure_message) + """ + good_for_packing = False + message = None + picking = move_line.picking_id + carrier = picking.ship_carrier_id or picking.carrier_id + if carrier: + actions = self._actions_for("packaging") + if actions.packaging_valid_for_carrier( + package.product_packaging_id, carrier + ): + good_for_packing = True + else: + message = self.msg_store.packaging_invalid_for_carrier( + package.product_packaging_id, carrier + ) + else: + message = self.msg_store.picking_without_carrier_cannot_pack(picking) + return good_for_packing, message + + def is_zero(self, move_line_id, zero): + """Confirm or not if the source location of a move has zero qty + + If the user confirms there is zero quantity, it means the stock was + correct and there is nothing to do. If the user says "no", a draft + empty inventory is created for the product (with lot if tracked). + + Transitions: + * select_line: whether the user confirms or not the location is empty, + go back to the picking of lines + """ + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + return self.list_move_lines() + + def _domain_stock_issue_unlink_lines(self, move_line): + # Since we have not enough stock, delete the move lines, which will + # in turn unreserve the moves. The moves lines we delete are those + # in the same location, and not yet started. + # The goal is to prevent the same operator to declare twice the same + # stock issue for the same product/lot/package. + move = move_line.move_id + lot = move_line.lot_id + package = move_line.package_id + location = move_line.location_id + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", move.product_id.id), + ("package_id", "=", package.id), + ("lot_id", "=", lot.id), + ("state", "not in", ("cancel", "done")), + ("qty_done", "=", 0), + ] + return domain + + def stock_issue(self, move_line_id): + """Declare a stock issue for a line + + After errors in the stock, the user cannot take all the products + because there is physically not enough goods. The move line is deleted + (unreserve), and an inventory is created to reduce the quantity in the + source location to prevent future errors until a correction. Beware: + the quantity already reserved and having a qty_done set on other lines + in the same location should remain reserved so the inventory's quantity + must be set to the total of qty_done of other lines. + + The other lines not yet picked (no qty_done) in the same location for + the same product, lot, package are unreserved as well (moves lines + deleted, which unreserve their quantity on the move). + + A second inventory is created in draft to have someone do an inventory + check. + + At the end, it tries to reserve the goods again, and if the current + line could be reserved in the current zone location, it transitions + directly to the screen to set the destination. + + Transitions: + * select_line: go back to the picking of lines for the next ones (nothing + could be reserved as replacement) + * set_line_destination: something could be reserved instead of the original + move line + """ + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + inventory = self._actions_for("inventory") + # create a draft inventory for a user to check + inventory.create_control_stock( + move_line.location_id, + move_line.product_id, + move_line.package_id, + move_line.lot_id, + ) + move = move_line.move_id + lot = move_line.lot_id + package = move_line.package_id + location = move_line.location_id + + # unreserve every lines for the same product/lot in the same location and + # not done yet, so the same user doesn't have to declare 2 times the + # stock issue for the same thing! + domain = self._domain_stock_issue_unlink_lines(move_line) + unreserve_move_lines = move_line | self.env["stock.move.line"].search(domain) + unreserve_moves = unreserve_move_lines.mapped("move_id").sorted() + unreserve_move_lines.unlink() + + # Then, create an inventory with just enough qty so the other assigned + # move lines for the same product in other batches and the other move lines + # already picked stay assigned. + inventory.create_stock_issue(move, location, package, lot) + + # try to reassign the moves in case we have stock in another location + unreserve_moves._action_assign() + + if move.move_line_ids: + return self._response_for_set_line_destination(move.move_line_ids[0]) + return self.list_move_lines() + + def change_pack_lot(self, move_line_id, barcode): + """Change the source package or the lot of a move line + + If the expected lot or package is at the very bottom of the location or + a stock error forces a user to change lot or package, user can change the + package or lot of the current move line. + + If the pack or lot was not supposed to be in the source location, + a draft inventory is created to have this checked. + + Transitions: + * change_pack_lot: the barcode scanned is invalid or change could not be done + * set_line_destination: the package / lot has been changed, can be + moved to destination now + * select_line: if the move line does not exist anymore + """ + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + search = self._actions_for("search") + # pre-configured callable used to generate the response as the + # change.package.lot component is not aware of the needed response type + # and related parameters for zone picking scenario + response_ok_func = functools.partial(self._response_for_set_line_destination) + response_error_func = functools.partial(self._response_for_change_pack_lot) + response = None + change_package_lot = self._actions_for("change.package.lot") + # handle lot + lot = search.lot_from_scan(barcode, products=move_line.product_id) + if lot: + response = change_package_lot.change_lot( + move_line, lot, response_ok_func, response_error_func + ) + # handle package + package = search.package_from_scan(barcode) + if package: + return change_package_lot.change_package( + move_line, package, response_ok_func, response_error_func + ) + # if the response is not an error, we check the move_line status + # to adapt the response ('set_line_destination' or 'select_line') + # TODO not sure to understand how 'move_line' could not exist here? + if response and response["message"]["message_type"] == "success": + # TODO adapt the response based on the move_line.exists() + if move_line.exists(): + return response + return response + + return self._response_for_change_pack_lot( + move_line, + message=self.msg_store.no_package_or_lot_for_barcode(barcode), + ) + + def prepare_unload(self): + """Initiate the unloading of the buffer + + The buffer is composed of move lines: + + * in the current zone location and picking type + * not done or canceled + * with a qty_done > 0 + * have a destination package + * with ``shopfloor_user_id`` equal to the current user + + The lines are grouped by their destination package. The destination + package is what is shown on the screen (with their content, which is + the move lines with the package as destination), and this is what is + passed along in the ``package_id`` parameters in the unload methods. + + It goes to different screens depending if there is only one move line, + or if all the move lines have the same destination or not. + + Transitions: + * unload_single: move lines have different destinations, return data + for the next destination package + * unload_set_destination: there is only one move line in the buffer + * unload_all: the move lines in the buffer all have the same + destination location + * select_line: no remaining move lines in buffer + """ + move_lines = self._find_buffer_move_lines() + location_dest = move_lines.mapped("location_dest_id") + if len(move_lines) == 1: + return self._response_for_unload_set_destination(move_lines) + elif len(move_lines) > 1 and len(location_dest) == 1: + return self._response_for_unload_all(move_lines) + elif len(move_lines) > 1 and len(location_dest) > 1: + return self._response_for_unload_single(first(move_lines)) + return self.list_move_lines() + + def _set_destination_all_response(self, buffer_lines, message=None): + if buffer_lines: + return self._response_for_unload_all(buffer_lines, message=message) + move_lines = self._find_location_move_lines() + if move_lines: + return self._response_for_select_line(move_lines, message=message) + return self._response_for_start(message=message) + + def set_destination_all(self, barcode, confirmation=False): + """Set the destination for all the lines in the buffer + + Look in ``prepare_unload`` for the definition of the buffer. + + This method must be used only if all the buffer's move lines which have + a destination package, qty done > 0, and have the same destination + location. + + A scanned location outside of the destination location of the operation + type is invalid. + + The move lines are then set to done, without backorders. + + Transitions: + * unload_all: the scanned destination is invalid, user has to + scan another one + * unload_all + confirmation: the scanned location is not in the + expected one but is valid (in picking type's default destination) + * select_line: no remaining move lines in buffer + """ + search = self._actions_for("search") + location = search.location_from_scan(barcode) + message = None + buffer_lines = self._find_buffer_move_lines() + if location: + error = None + location_dest = buffer_lines.mapped("location_dest_id") + # check if move lines share the same destination + if len(location_dest) != 1: + error = self.msg_store.lines_different_dest_location() + # check if the scanned location is allowed + moves = buffer_lines.mapped("move_id") + if not self.is_dest_location_valid(moves, location): + error = self.msg_store.location_not_allowed() + if error: + return self._set_destination_all_response(buffer_lines, message=error) + # check if the destination location is not the expected one + # - OK if the scanned destination is a child of the current + # destination set on buffer lines + # - To confirm if the scanned destination is not a child of the + # current destination set on buffer lines + if not confirmation and self.is_dest_location_to_confirm( + buffer_lines.location_dest_id, location + ): + return self._response_for_unload_all( + buffer_lines, + message=self.msg_store.confirm_location_changed( + first(buffer_lines.location_dest_id), location + ), + confirmation_required=True, + ) + # the scanned location is still valid, use it + self._write_destination_on_lines(buffer_lines, location) + stock = self._actions_for("stock") + stock.validate_moves(moves) + message = self.msg_store.buffer_complete() + buffer_lines = self._find_buffer_move_lines() + else: + message = self.msg_store.no_location_found() + return self._set_destination_all_response(buffer_lines, message=message) + + def _write_destination_on_lines(self, lines, location): + self._lock_lines(lines) + lines.location_dest_id = location + lines.package_level_id.location_dest_id = location + if self.work.menu.unload_package_at_destination: + lines.result_package_id = False + + def unload_split(self): + """Indicates that now the buffer must be treated line per line + + Called from a button, users decides to handle destinations one by one. + Even if the move lines to unload all have the same destination. + + Look in ``prepare_unload`` for the definition of the buffer. + + Transitions: + * unload_single: more than one remaining line in the buffer + * unload_set_destination: there is only one remaining line in the buffer + * select_line: no remaining move lines in buffer + """ + buffer_lines = self._find_buffer_move_lines() + # more than one remaining move line in the buffer + if len(buffer_lines) > 1: + return self._response_for_unload_single(first(buffer_lines)) + # only one move line to process in the buffer + elif len(buffer_lines) == 1: + return self._response_for_unload_set_destination(first(buffer_lines)) + # no remaining move lines in buffer + move_lines = self._find_location_move_lines() + return self._response_for_select_line( + move_lines, + message=self.msg_store.buffer_complete(), + ) + + def _unload_response(self, unload_single_message=None): + """Prepare the right response depending on the move lines to process.""" + # if there are still move lines to process from the buffer + move_lines = self._find_buffer_move_lines() + if move_lines: + return self._response_for_unload_single( + first(move_lines), + message=unload_single_message, + ) + # if there are still move lines to process from the picking type + # => buffer complete! + move_lines = self._find_location_move_lines() + if move_lines: + return self._response_for_select_line( + move_lines, + message=self.msg_store.buffer_complete(), + ) + # no more move lines to process from the current picking type + # => picking type complete! + return self._response_for_start( + message=self.msg_store.picking_type_complete(self.picking_type) + ) + + def unload_scan_pack(self, package_id, barcode): + """Scan the destination package to check user moves the good one + + The "unload_single" screen proposes a package (which has been + previously been set as destination package of lines of the buffer). + The user has to scan the package to validate they took the good one. + + Transitions: + * unload_single: the scanned barcode does not match the package + * unload_set_destination: the scanned barcode matches the package + * select_line: no remaining move lines in buffer + * start: no remaining move lines in picking type + """ + package = self.env["stock.quant.package"].browse(package_id) + if not package.exists(): + return self._unload_response( + unload_single_message=self.msg_store.record_not_found(), + ) + search = self._actions_for("search") + scanned_package = search.package_from_scan(barcode) + # the scanned barcode matches the package + if scanned_package == package: + move_lines = self._find_buffer_move_lines(dest_package=scanned_package) + if move_lines: + return self._response_for_unload_set_destination(first(move_lines)) + return self._unload_response( + unload_single_message=self.msg_store.barcode_no_match(package.name), + ) + + def _lock_lines(self, lines): + """Lock move lines""" + self._actions_for("lock").for_update(lines) + + def unload_set_destination(self, package_id, barcode, confirmation=False): + """Scan the final destination for move lines in the buffer with the + destination package + + All the move lines in the buffer with the package_id as destination + package are updated with the scanned location. + + The move lines are then set to done, without backorders. + + Look in ``prepare_unload`` for the definition of the buffer. + + Transitions: + * unload_single: buffer still contains move lines, unload the next package + * unload_set_destination: the scanned location is invalid, user has to + scan another one + * unload_set_destination+confirmation_required: the scanned location is not + in the expected one but is valid (in picking type's default destination) + * select_line: no remaining move lines in buffer + * start: no remaining move lines to process in the picking type + """ + package = self.env["stock.quant.package"].browse(package_id) + buffer_lines = self._find_buffer_move_lines(dest_package=package) + if not package.exists() or not buffer_lines: + move_lines = self._find_location_move_lines() + return self._response_for_select_line( + move_lines, + message=self.msg_store.record_not_found(), + ) + search = self._actions_for("search") + location = search.location_from_scan(barcode) + if location: + moves = buffer_lines.mapped("move_id") + if not self.is_dest_location_valid(moves, location): + return self._response_for_unload_set_destination( + first(buffer_lines), + message=self.msg_store.dest_location_not_allowed(), + ) + # check if the destination location is not the expected one + # - OK if the scanned destination is a child of the current + # destination set on buffer lines + # - To confirm if the scanned destination is not a child of the + # current destination set on buffer lines + if not confirmation and self.is_dest_location_to_confirm( + buffer_lines.location_dest_id, location + ): + return self._response_for_unload_set_destination( + first(buffer_lines), + message=self.msg_store.confirm_location_changed( + first(buffer_lines.location_dest_id), location + ), + confirmation_required=True, + ) + # the scanned location is valid, use it + self._write_destination_on_lines(buffer_lines, location) + # set lines to done + refresh buffer lines (should be empty) + # split move lines to a backorder move + # if quantity is not fully satisfied + for move in moves: + move.split_other_move_lines(buffer_lines & move.move_line_ids) + + stock = self._actions_for("stock") + stock.validate_moves(moves) + buffer_lines = self._find_buffer_move_lines() + + if buffer_lines: + # TODO: return success message if line has been processed + return self._response_for_unload_single(first(buffer_lines)) + move_lines = self._find_location_move_lines() + if move_lines: + return self._response_for_select_line( + move_lines, + message=self.msg_store.buffer_complete(), + ) + return self._response_for_start( + message=self.msg_store.picking_type_complete(self.picking_type) + ) + # TODO: when we have no lines here + # we should not redirect to `unload_set_destination` + # because we'll have nothing to display (currently the UI is broken). + return self._response_for_unload_set_destination( + first(buffer_lines), + message=self.msg_store.no_location_found(), + ) + + +class ShopfloorZonePickingValidator(Component): + """Validators for the Zone Picking endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.zone_picking.validator" + _usage = "zone_picking.validator" + + def select_zone(self): + return {} + + def scan_location(self): + return {"barcode": {"required": True, "type": "string"}} + + def list_move_lines(self): + return { + "barcode": {"required": False, "nullable": True, "type": "string"}, + } + + def scan_source(self): + return { + "barcode": {"required": False, "nullable": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + "product_id": {"required": False, "nullable": True, "type": "integer"}, + "sublocation_id": {"required": False, "nullable": True, "type": "integer"}, + "package_id": {"required": False, "nullable": True, "type": "integer"}, + } + + def set_destination(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, + "quantity": { + "coerce": to_float, + "required": True, + "type": "float", + }, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + def is_zero(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "zero": {"coerce": to_bool, "required": True, "type": "boolean"}, + } + + def stock_issue(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def change_pack_lot(self): + return { + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, + } + + def prepare_unload(self): + return {} + + def set_destination_all(self): + return { + "barcode": {"required": False, "nullable": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + def unload_split(self): + return {} + + def unload_scan_pack(self): + return { + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, + } + + def unload_set_destination(self): + return { + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": False, "nullable": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + +class ShopfloorZonePickingValidatorResponse(Component): + """Validators for the Zone Picking endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.zone_picking.validator.response" + _usage = "zone_picking.validator.response" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "start": self._schema_for_select_zone, + "select_picking_type": self._schema_for_select_picking_type, + "select_line": self._schema_for_move_lines_empty_location, + "set_line_destination": self._schema_for_move_line, + "zero_check": self._schema_for_zero_check, + "change_pack_lot": self._schema_for_move_line, + "unload_all": self._schema_for_move_lines, + "unload_single": self._schema_for_move_line, + "unload_set_destination": self._schema_for_move_line, + } + + def select_zone(self): + return self._response_schema(next_states={"start"}) + + def scan_location(self): + return self._response_schema(next_states={"start", "select_picking_type"}) + + def list_move_lines(self): + return self._response_schema(next_states={"select_line"}) + + def scan_source(self): + return self._response_schema( + next_states={"select_line", "set_line_destination"} + ) + + def set_destination(self): + return self._response_schema( + next_states={"select_line", "set_line_destination", "zero_check"} + ) + + def is_zero(self): + return self._response_schema(next_states={"select_line"}) + + def stock_issue(self): + return self._response_schema( + next_states={"select_line", "set_line_destination"} + ) + + def change_pack_lot(self): + return self._response_schema( + next_states={"change_pack_lot", "set_line_destination", "select_line"} + ) + + def prepare_unload(self): + return self._response_schema( + next_states={ + "unload_all", + "unload_single", + "unload_set_destination", + "select_line", + } + ) + + def set_destination_all(self): + return self._response_schema(next_states={"unload_all", "select_line"}) + + def unload_split(self): + return self._response_schema( + next_states={"unload_single", "unload_set_destination", "select_line"} + ) + + def unload_scan_pack(self): + return self._response_schema( + next_states={ + "unload_single", + "unload_set_destination", + "select_line", + "start", + } + ) + + def unload_set_destination(self): + return self._response_schema( + next_states={"unload_single", "unload_set_destination", "select_line"} + ) + + @property + def _schema_for_select_zone(self): + zone_schema = self.schemas.location() + picking_type_schema = self.schemas.picking_type() + picking_type_schema.update(self._schema_for_zone_line_counters) + zone_schema["operation_types"] = self.schemas._schema_list_of( + picking_type_schema + ) + zone_schema.update(self._schema_for_zone_line_counters) + zone_schema = { + "zones": self.schemas._schema_list_of(zone_schema), + "buffer": { + "type": "dict", + "nullable": False, + "required": False, + "schema": { + "zone_location": self.schemas._schema_dict_of( + self.schemas.location(), nullable=False, required=False + ), + "picking_type": self.schemas._schema_dict_of( + self.schemas.picking_type(), nullable=False, required=False + ), + }, + }, + } + return zone_schema + + @property + def _schema_for_zone_line_counters(self): + return self.schemas.move_lines_counters() + + @property + def _schema_for_select_picking_type(self): + picking_type = self.schemas.picking_type() + picking_type.update(self._schema_for_zone_line_counters) + schema = { + "zone_location": self.schemas._schema_dict_of(self.schemas.location()), + "picking_types": self.schemas._schema_list_of(picking_type), + } + return schema + + @property + def _schema_for_move_line(self): + schema = { + "zone_location": self.schemas._schema_dict_of(self.schemas.location()), + "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()), + "move_line": self.schemas._schema_dict_of( + self.schemas.move_line(with_picking=True) + ), + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, + "product_id": { + "type": "integer", + "nullable": True, + "required": False, + }, + } + return schema + + @property + def _schema_for_move_lines(self): + schema = { + "zone_location": self.schemas._schema_dict_of(self.schemas.location()), + "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()), + "move_lines": self.schemas._schema_list_of( + self.schemas.move_line(with_picking=True) + ), + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, + "product": self.schemas._schema_dict_of( + self.schemas.product(), required=False + ), + "sublocation": self.schemas._schema_dict_of( + self.schemas.location(), required=False + ), + "package": self.schemas._schema_dict_of( + self.schemas.package(), required=False + ), + } + return schema + + @property + def _schema_for_move_lines_empty_location(self): + schema = self._schema_for_move_lines + schema["move_lines"]["schema"]["schema"]["location_will_be_empty"] = { + "type": "boolean", + "nullable": False, + "required": True, + } + schema["scan_location_or_pack_first"] = { + "type": "boolean", + "nullable": False, + "required": True, + } + return schema + + @property + def _schema_for_zero_check(self): + schema = { + "zone_location": self.schemas._schema_dict_of(self.schemas.location()), + "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()), + "location": self.schemas._schema_dict_of(self.schemas.location()), + "move_line": self.schemas._schema_dict_of(self.schemas.move_line()), + } + return schema diff --git a/shopfloor/static/description/icon.png b/shopfloor/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/shopfloor/static/description/icon.png differ diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html new file mode 100644 index 0000000000..2bda32e83a --- /dev/null +++ b/shopfloor/static/description/index.html @@ -0,0 +1,503 @@ + + + + + +Shopfloor + + + +
+

Shopfloor

+ + +

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

+

Shopfloor is a barcode scanner application for internal warehouse operations.

+

The application supports scenarios, to relate to Operation Types:

+
    +
  • Cluster Picking
  • +
  • Zone Picking
  • +
  • Checkout/Packing
  • +
  • Delivery
  • +
  • Location Content Transfer
  • +
  • Single Pack Transfer
  • +
+

This module provides REST APIs to support the scenarios. It needs a frontend +to consume the backend APIs and provide screens for users on barcode devices. +A default front-end application is provided by shopfloor_mobile.

+
+
Note: if you want to enable a new scenario on an existing application, you must trigger the registry sync on the shopfloor.app in a post_init_hook or a post-migrate script.
+
See an example here.
+
+

Table of contents

+ +
+

Usage

+

An API key is created in the Demo data (for development), using +the Demo user. The key to use in the HTTP header API-KEY is: 72B044F7AC780DAC

+

Curl example:

+
+curl -X POST "http://localhost:8069/shopfloor/user/menu" -H  "accept: */*" -H  "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
+
+
+
+

Known issues / Roadmap

+
    +
  • improve documentation
  • +
  • split out scenario components to their own modules
  • +
  • maybe split common stock features to shopfloor_stock_base +and move scenario to shopfloor_wms?
  • +
+
+
+

Changelog

+
+

13.0.1.0.0

+

First official version.

+
+
+
+

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

+
    +
  • Camptocamp
  • +
  • BCIM
  • +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Design

+ +
+
+

Other credits

+

Financial support

+
    +
  • Cosanum
  • +
  • Camptocamp R&D
  • +
  • Akretion R&D
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

Current maintainers:

+

guewen simahawk sebalix

+

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/tests/__init__.py b/shopfloor/tests/__init__.py new file mode 100644 index 0000000000..d17286e24d --- /dev/null +++ b/shopfloor/tests/__init__.py @@ -0,0 +1,83 @@ +from . import test_menu_counters +from . import test_openapi +from . import test_actions_change_package_lot +from . import test_actions_data +from . import test_actions_data_detail +from . import test_actions_search +from . import test_actions_stock +from . import test_single_pack_transfer +from . import test_single_pack_transfer_putaway +from . import test_cluster_picking_base +from . import test_cluster_picking_batch +from . import test_cluster_picking_select +from . import test_cluster_picking_scan_line +from . import test_cluster_picking_scan_line_location_or_pack_first +from . import test_cluster_picking_scan_line_no_prefill_qty +from . import test_cluster_picking_scan_destination +from . import test_cluster_picking_scan_destination_no_prefill_qty +from . import test_cluster_picking_is_zero +from . import test_cluster_picking_skip +from . import test_cluster_picking_stock_issue +from . import test_cluster_picking_change_pack_lot +from . import test_cluster_picking_unload +from . import test_checkout_base +from . import test_checkout_scan +from . import test_checkout_select +from . import test_checkout_scan_line +from . import test_checkout_scan_line_no_prefill_qty +from . import test_checkout_scan_line_base +from . import test_checkout_select_line +from . import test_checkout_select_package_base +from . import test_checkout_set_qty +from . import test_checkout_scan_package_action +from . import test_checkout_scan_package_action_no_prefill_qty +from . import test_checkout_new_package +from . import test_checkout_no_package +from . import test_checkout_auto_post +from . import test_checkout_list_delivery_packaging +from . import test_checkout_list_package +from . import test_checkout_summary +from . import test_checkout_change_packaging +from . import test_checkout_cancel_line +from . import test_checkout_done +from . import test_delivery_base +from . import test_delivery_done +from . import test_delivery_scan_deliver +from . import test_delivery_reset_qty_done_line +from . import test_delivery_reset_qty_done_pack +from . import test_delivery_set_qty_done_pack +from . import test_delivery_set_qty_done_line +from . import test_delivery_sublocation +from . import test_delivery_list_stock_picking +from . import test_delivery_select +from . import test_location_content_transfer_base +from . import test_location_content_transfer_start +from . import test_location_content_transfer_get_work +from . import test_location_content_transfer_set_destination_all +from . import test_location_content_transfer_scan_location +from . import test_location_content_transfer_single +from . import test_location_content_transfer_set_destination_package_or_line +from . import test_location_content_transfer_putaway +from . import test_location_content_transfer_mix +from . import test_zone_picking_base +from . import test_zone_picking_start +from . import test_zone_picking_select_picking_type +from . import test_zone_picking_select_line +from . import test_zone_picking_select_line_no_prefill_qty +from . import test_zone_picking_select_line_first_scan_location +from . import test_zone_picking_set_line_destination +from . import test_zone_picking_set_line_destination_no_prefill_qty +from . import test_zone_picking_set_line_destination_pick_pack +from . import test_zone_picking_zero_check +from . import test_zone_picking_stock_issue +from . import test_zone_picking_change_pack_lot +from . import test_zone_picking_unload_buffer_lines +from . import test_zone_picking_unload_single +from . import test_zone_picking_unload_all +from . import test_zone_picking_unload_set_destination +from . import test_misc +from . import test_move_action_assign +from . import test_scan_anything +from . import test_stock_split +from . import test_picking_form +from . import test_user diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py new file mode 100644 index 0000000000..caf0b8977d --- /dev/null +++ b/shopfloor/tests/common.py @@ -0,0 +1,324 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from collections import namedtuple + +from odoo import models +from odoo.tests.common import Form + +from odoo.addons.shopfloor_base.tests.common import CommonCase as BaseCommonCase + + +# pylint: disable=missing-return +class CommonCase(BaseCommonCase): + @classmethod + def setUpShopfloorApp(cls): + cls.shopfloor_app = cls.env.ref("shopfloor.app_demo").with_user( + cls.shopfloor_user + ) + + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + stock_location = cls.env.ref("stock.stock_location_stock") + cls.stock_location = stock_location + cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.dispatch_location = cls.env.ref("stock.location_dispatch_zone") + cls.packing_location = cls.env.ref("stock.location_pack_zone") + cls.input_location = cls.env.ref("stock.stock_location_company") + cls.shelf1 = cls.env.ref("stock.stock_location_components") + cls.shelf2 = cls.env.ref("stock.stock_location_14") + + @classmethod + def _shopfloor_user_values(cls): + vals = super()._shopfloor_user_values() + vals["groups_id"] = [ + ( + 6, + 0, + [ + cls.env.ref("stock.group_stock_user").id, + cls.env.ref("stock.group_stock_multi_locations").id, + ], + ) + ] + return vals + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.customer = cls.env["res.partner"].sudo().create({"name": "Customer"}) + + cls.customer_location.sudo().barcode = "CUSTOMERS" + cls.dispatch_location.sudo().barcode = "DISPATCH" + cls.packing_location.sudo().barcode = "PACKING" + cls.input_location.sudo().barcode = "INPUT" + cls.shelf1.sudo().barcode = "SHELF1" + cls.shelf2.sudo().barcode = "SHELF2" + + cls.product_a = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product A", + "type": "product", + "default_code": "A", + "barcode": "A", + "weight": 2, + } + ) + ) + cls.product_a_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_a.id, + "barcode": "ProductABox", + "qty": 3.0, + } + ) + ) + cls.product_b = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product B", + "type": "product", + "default_code": "B", + "barcode": "B", + "weight": 3, + } + ) + ) + cls.product_b_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_b.id, + "barcode": "ProductBBox", + } + ) + ) + cls.product_c = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product C", + "type": "product", + "default_code": "C", + "barcode": "C", + "weight": 3, + } + ) + ) + cls.product_c_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_c.id, + "barcode": "ProductCBox", + "qty": 3, + } + ) + ) + cls.product_d = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product D", + "type": "product", + "default_code": "D", + "barcode": "D", + "weight": 3, + } + ) + ) + cls.product_d_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_d.id, + "barcode": "ProductDBox", + } + ) + ) + + @classmethod + def _create_picking(cls, picking_type=None, lines=None, confirm=True, **kw): + picking_form = Form(cls.env["stock.picking"]) + picking_form.picking_type_id = picking_type or cls.picking_type + picking_form.partner_id = cls.customer + if lines is None: + lines = [(cls.product_a, 10), (cls.product_b, 10)] + for product, qty in lines: + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = qty + for k, v in kw.items(): + setattr(picking_form, k, v) + picking = picking_form.save() + if confirm: + picking.action_confirm() + return picking + + @classmethod + def _update_qty_in_location( + cls, location, product, quantity, package=None, lot=None + ): + quants = cls.env["stock.quant"]._gather( + product, location, lot_id=lot, package_id=package, strict=True + ) + # this method adds the quantity to the current quantity, so remove it + quantity -= sum(quants.mapped("quantity")) + cls.env["stock.quant"]._update_available_quantity( + product, location, quantity, package_id=package, lot_id=lot + ) + + @classmethod + def _fill_stock_for_moves( + cls, moves, in_package=False, same_package=True, in_lot=False, location=False + ): + """Satisfy stock for given moves. + + :param moves: stock.move recordset + :param in_package: stock.quant.package record or simple boolean + If a package record is given, it will be used as package. + If a boolean true is given, a new package will be created for each move. + :param same_package: + modify the behavior of `in_package` to use the same package for all moves. + :param in_lot: stock.lot record or simple boolean + If a lot record is given, it will be used as lot. + If a boolean true is given, a new lot will be created. + """ + product_packages = {} + product_locations = {} + package = None + if in_package: + if isinstance(in_package, models.BaseModel): + package = in_package + else: + package = cls.env["stock.quant.package"].create({}) + for move in moves: + key = (move.product_id, location or move.location_id) + product_locations.setdefault(key, 0) + product_locations[key] += move.product_qty + if in_package: + if isinstance(in_package, models.BaseModel): + package = in_package + if not package or package and not same_package: + package = cls.env["stock.quant.package"].create({}) + product_packages[key] = package + for (product, location), qty in product_locations.items(): + lot = None + if in_lot: + if isinstance(in_lot, models.BaseModel): + lot = in_lot + else: + lot = cls.env["stock.lot"].create( + {"product_id": product.id, "company_id": cls.env.company.id} + ) + if not (in_lot or in_package): + # always add more quantity in stock to avoid to trigger the + # "zero checks" in tests, not for lots which must have a qty + # of 1 and not for packages because we need the strict number + # of units to pick a package + qty *= 2 + cls._update_qty_in_location( + location, product, qty, package=package, lot=lot + ) + + # used by _create_package_in_location + PackageContent = namedtuple( + "PackageContent", + # recordset of the product, + # quantity in float + # recordset of the lot (optional) + "product quantity lot", + ) + + def _create_package_in_location(self, location, content): + """Create a package and quants in a location + + content is a list of PackageContent + """ + package = self.env["stock.quant.package"].create({}) + for product, quantity, lot in content: + self._update_qty_in_location( + location, product, quantity, package=package, lot=lot + ) + return package + + def _create_lot(self, product): + return self.env["stock.lot"].create( + {"product_id": product.id, "company_id": self.env.company.id} + ) + + +class PickingBatchMixin: + + BatchProduct = namedtuple( + "BatchProduct", + # browse record of the product, + # quantity in float + "product quantity", + ) + + @classmethod + def _create_picking_batch(cls, products): + """Create a picking batch + + :param products: list of list of BatchProduct. The outer list creates + pickings and the innerr list creates moves in these pickings + """ + batch_form = Form(cls.env["stock.picking.batch"].sudo()) + for transfer in products: + picking_form = Form(cls.env["stock.picking"].sudo()) + picking_form.picking_type_id = cls.picking_type + picking_form.location_id = cls.stock_location + picking_form.origin = "test" + picking_form.partner_id = cls.customer + for batch_product in transfer: + product = batch_product.product + quantity = batch_product.quantity + with picking_form.move_ids_without_package.new() as move: + move.product_id = product + move.product_uom_qty = quantity + picking = picking_form.save() + batch_form.picking_ids.add(picking) + + batch = batch_form.save() + batch.picking_ids.action_confirm() + batch.picking_ids.action_assign() + return batch + + @classmethod + def _simulate_batch_selected( + cls, batches, in_package=False, in_lot=False, fill_stock=True + ): + """Create a state as if a batch was selected by the user + + * The picking batch is in progress + * It is assigned to the current user + * All the move lines are available + + Note: currently, this method create a source package that contains + all the products of the batch. It is enough for the current tests. + """ + pickings = batches.mapped("picking_ids") + if fill_stock: + cls._fill_stock_for_moves( + pickings.mapped("move_ids"), in_package=in_package, in_lot=in_lot + ) + pickings.action_assign() + batches.write({"state": "in_progress", "user_id": cls.env.uid}) diff --git a/shopfloor/tests/models.py b/shopfloor/tests/models.py new file mode 100644 index 0000000000..63bbee06ec --- /dev/null +++ b/shopfloor/tests/models.py @@ -0,0 +1,29 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Test data models to get a mapping between carrier and delivery packaging. + +Add a new "test" value for 'delivery_type' field of carrier and +'package_carrier_type' field of packaging for test purpose because +Shopfloor do not depend on 'delivery_*' modules adding the different +delivery types. +""" +from odoo import fields, models + + +class DeliveryCarrierTest(models.Model): + _inherit = "delivery.carrier" + + delivery_type = fields.Selection( + selection_add=[("test", "TEST")], ondelete={"test": "set default"} + ) + test_default_packaging_id = fields.Many2one( + "stock.package.type", string="Default Package Type" + ) + + +class StockPackageType(models.Model): + _inherit = "stock.package.type" + + package_carrier_type = fields.Selection( + selection_add=[("test", "TEST")], ondelete={"test": "set default"} + ) diff --git a/shopfloor/tests/test_actions_change_package_lot.py b/shopfloor/tests/test_actions_change_package_lot.py new file mode 100644 index 0000000000..ebc4931f83 --- /dev/null +++ b/shopfloor/tests/test_actions_change_package_lot.py @@ -0,0 +1,1175 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.tests.common import Form + +from .common import CommonCase + + +# pylint: disable=missing-return +class TestActionsChangePackageLot(CommonCase): + """Tests covering changing a package on a move line""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + with cls.work_on_actions(cls) as work: + cls.change_package_lot = work.component(usage="change.package.lot") + + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + cls.wh = cls.env.ref("stock.warehouse0") + cls.picking_type = cls.wh.out_type_id + cls.picking_type.sudo().show_entire_packs = True + + def _create_picking_with_package_level(self, packages): + picking_form = Form( + self.env["stock.picking"].with_context(force_detailed_view=True) + ) + picking_form.partner_id = self.customer + picking_form.origin = "test" + picking_form.picking_type_id = self.picking_type + picking_form.location_id = self.stock_location + for package in packages: + with picking_form.package_level_ids_details.new() as move: + move.package_id = package + picking = picking_form.save() + picking.action_confirm() + picking.action_assign() + return picking + + def assert_quant_reserved_qty(self, move_line, qty_func, package=None, lot=None): + domain = [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ] + if package: + domain.append(("package_id", "=", package.id)) + if lot: + domain.append(("lot_id", "=", lot.id)) + quant = self.env["stock.quant"].search(domain) + self.assertEqual(quant.reserved_quantity, qty_func()) + + def assert_quant_package_qty(self, location, package, qty_func): + quant = self.env["stock.quant"].search( + [("location_id", "=", location.id), ("package_id", "=", package.id)] + ) + self.assertEqual(quant.quantity, qty_func()) + + @staticmethod + def unreachable_func(move_line, message=None): + raise AssertionError("should not reach this function") + + def test_change_lot_ok(self): + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + source_location = line.location_id + new_lot = self._create_lot(self.product_a) + # ensure we have our new package in the same location + self._update_qty_in_location(source_location, line.product_id, 10, lot=new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues(line, [{"lot_id": new_lot.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: line.reserved_qty, lot=new_lot) + + def test_change_lot_less_quantity_ok(self): + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + source_location = line.location_id + new_lot = self._create_lot(self.product_a) + # ensure we have our new package in the same location + self._update_qty_in_location(source_location, line.product_id, 8, lot=new_lot) + expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + expected_message["body"] += " The quantity to do has changed!" + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues(line, [{"lot_id": new_lot.id, "reserved_qty": 8}]) + other_line = line.move_id.move_line_ids - line + self.assertRecordValues( + other_line, [{"lot_id": initial_lot.id, "reserved_qty": 2}] + ) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 2, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: line.reserved_qty, lot=new_lot) + + def test_change_lot_zero_quant_error(self): + """No quant in the location for the scanned lot + + As the user scanned it, it's an inventory error. + """ + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + new_lot = self._create_lot(self.product_a) + expected_message = self.msg_store.cannot_change_lot_already_picked(new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + ) + + self.assertRecordValues(line, [{"lot_id": initial_lot.id, "reserved_qty": 10}]) + # check that reservations have not been updated + self.assert_quant_reserved_qty(line, lambda: line.reserved_qty, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: 0, lot=new_lot) + + def test_change_lot_package_explode_ok(self): + """Scan a lot on units replacing a package""" + initial_lot = self._create_lot(self.product_a) + package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.lot_id, initial_lot) + self.assertEqual(line.package_id, package) + + new_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=new_lot) + expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues( + line, + [ + { + "lot_id": new_lot.id, + "reserved_qty": 10, + "package_id": False, + "package_level_id": False, + } + ], + ) + + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: line.reserved_qty, lot=new_lot) + + def test_change_lot_reserved_qty_ok(self): + """Scan a lot already reserved by other lines + + It should unreserve the other line, use the lot for the current line, + and re-reserve the other move. + """ + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.lot_id, initial_lot) + + new_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=new_lot) + picking2 = self._create_picking(lines=[(self.product_a, 10)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.lot_id, new_lot) + + expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues(line, [{"lot_id": new_lot.id, "reserved_qty": 10}]) + # line has been re-created + line2 = picking2.move_line_ids + self.assertRecordValues(line2, [{"lot_id": initial_lot.id, "reserved_qty": 10}]) + + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: line.reserved_qty, lot=new_lot) + self.assert_quant_reserved_qty( + line2, lambda: line2.reserved_qty, lot=initial_lot + ) + + def test_change_lot_reserved_partial_qty_ok(self): + """Scan a lot already reserved by other lines and can only be reserved + partially + + It should unreserve the other line, use the lot for the current line, + and re-reserve the other move. The quantity for the current line must + be adapted to the available + """ + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.lot_id, initial_lot) + + new_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 8, lot=new_lot) + picking2 = self._create_picking(lines=[(self.product_a, 8)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.lot_id, new_lot) + + expected_message = self.msg_store.lot_replaced_by_lot(initial_lot, new_lot) + expected_message["body"] += " The quantity to do has changed!" + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues(line, [{"lot_id": new_lot.id, "reserved_qty": 8}]) + other_line = picking.move_line_ids - line + self.assertRecordValues( + other_line, [{"lot_id": initial_lot.id, "reserved_qty": 2}] + ) + # line has been re-created + line2 = picking2.move_line_ids + self.assertRecordValues(line2, [{"lot_id": initial_lot.id, "reserved_qty": 8}]) + + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: line.reserved_qty, lot=new_lot) + # both line2 and the line for the 2 remaining will re-reserve the initial lot + self.assert_quant_reserved_qty( + other_line, + lambda: line2.reserved_qty + other_line.reserved_qty, + lot=initial_lot, + ) + + def test_change_lot_reserved_qty_done_error(self): + """Scan a lot already reserved by other *picked* lines + + Cannot "steal" lot from picked lines + """ + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.lot_id, initial_lot) + + new_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=new_lot) + picking2 = self._create_picking(lines=[(self.product_a, 10)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.lot_id, new_lot) + line2.qty_done = 10.0 + + expected_message = self.msg_store.cannot_change_lot_already_picked(new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + ) + + # no changes + self.assertRecordValues(line, [{"lot_id": initial_lot.id, "reserved_qty": 10}]) + self.assertRecordValues( + line2, [{"lot_id": new_lot.id, "reserved_qty": 10, "qty_done": 10.0}] + ) + self.assert_quant_reserved_qty(line, lambda: line.reserved_qty, lot=initial_lot) + self.assert_quant_reserved_qty(line2, lambda: line2.reserved_qty, lot=new_lot) + + def test_change_lot_different_location_error(self): + "If the scanned lot is in a different location, we cannot process it" + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + new_lot = self._create_lot(self.product_a) + # ensure we have our new lot in a different location + self._update_qty_in_location(self.shelf2, line.product_id, 10, lot=new_lot) + expected_message = self.msg_store.cannot_change_lot_already_picked(new_lot) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + ) + + self.assertRecordValues(line, [{"lot_id": initial_lot.id}]) + # check that reservations have not been updated + self.assert_quant_reserved_qty(line, lambda: line.reserved_qty, lot=initial_lot) + self.assert_quant_reserved_qty(line, lambda: 0, lot=new_lot) + + def test_change_lot_in_several_packages_error(self): + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + # create 2 packages for the same new lot in the same location + new_lot = self._create_lot(self.product_a) + self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)] + ) + self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, new_lot)] + ) + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.several_packs_in_location(self.shelf1) + ), + ) + + def test_change_lot_in_package_ok(self): + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=initial_lot)] + ) + # ensure we have our new package in the same location + new_lot = self._create_lot(self.product_a) + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=new_lot)] + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues( + line, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "lot_id": new_lot.id, + "reserved_qty": 10.0, + } + ], + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line, lambda: line.reserved_qty, package=new_package + ) + + def test_change_lot_in_package_no_initial_package_ok(self): + self.product_a.tracking = "lot" + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + # ensure we have our new package in the same location + new_lot = self._create_lot(self.product_a) + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=new_lot)] + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.change_package_lot.change_lot( + line, + new_lot, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.units_replaced_by_package(new_package) + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues( + line, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "lot_id": new_lot.id, + "reserved_qty": 10.0, + } + ], + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, lot=initial_lot) + self.assert_quant_reserved_qty( + line, lambda: line.reserved_qty, package=new_package + ) + + def test_change_pack_different_content_error(self): + # create the initial package, that will be reserved first + initial_package = self._create_package_in_location( + self.shelf1, + [ + self.PackageContent(self.product_a, 10, lot=None), + self.PackageContent(self.product_b, 10, lot=None), + ], + ) + picking = self._create_picking_with_package_level(initial_package) + # create a new package in the same location + # with a different content + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_b, 8, lot=None)] + ) + + lines = picking.move_line_ids + # try to use the new package, which doesn't contain our product, + # cannot be changed + self.change_package_lot.change_package( + lines[0], + new_package, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.package_different_content(new_package) + ), + ) + + def test_change_pack_multi_content_with_lot(self): + """Switch package for a line which was part of a multi-products package + + We have a move line which is part of a package with more than one + product and the other product is moved by another move line. + + We want to pick the goods for product A in a different package. What + should happen is: + + * the package level is exploded, as we will no longer move the entire + package + * the move line for product A should now use the new package, and be + updated with the lot of the package + * the move line for the other product should keep the other package, if + the user want to change the package for the other product too, they + can do it when they pick it + """ + (self.product_a + self.product_b).tracking = "lot" + # create a package with 2 products tracked by lot, stored in shelf1 + # this package is reserved first on the move line + initial_lot_a = self._create_lot(self.product_a) + initial_lot_b = self._create_lot(self.product_b) + initial_package = self._create_package_in_location( + self.shelf1, + [ + self.PackageContent(self.product_a, 10, initial_lot_a), + self.PackageContent(self.product_b, 10, initial_lot_b), + ], + ) + + # create and reserve our transfer using the initial package + picking = self._create_picking_with_package_level(initial_package) + + lines = picking.move_line_ids + + # create a second package with the same content, which will be used + # as replacement + new_lot_a = self._create_lot(self.product_a) + new_lot_b = self._create_lot(self.product_b) + new_package = self._create_package_in_location( + self.shelf1, + [ + self.PackageContent(self.product_a, 10, new_lot_a), + self.PackageContent(self.product_b, 10, new_lot_b), + ], + ) + line1, line2 = lines + self.change_package_lot.change_package( + line1, + new_package, + # success callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues( + line1, + [ + { + "package_id": new_package.id, + # we are no longer moving an entire package + "result_package_id": False, + "lot_id": new_lot_a.id, + "reserved_qty": 10.0, + } + ], + ) + self.assertRecordValues( + line2, + [ + { + "package_id": initial_package.id, + # we are no longer moving an entire package + "result_package_id": False, + "lot_id": initial_lot_b.id, + "reserved_qty": 10.0, + } + ], + ) + # check that reservations have been updated + self.assert_quant_reserved_qty(line1, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line2, lambda: line2.reserved_qty, package=initial_package + ) + self.assert_quant_reserved_qty( + line1, lambda: line1.reserved_qty, package=new_package + ) + self.assert_quant_reserved_qty(line2, lambda: 0, package=new_package) + + def test_change_pack_different_location(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + # put a package in shelf2 in the system, but we assume that in real, + # the operator put it in shelf1 + new_package = self._create_package_in_location( + self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.package_id, initial_package) + # when the operator wants to pick the initial package, in shelf1, the new + # package is in front of the other so they want to change the package + self.change_package_lot.change_package( + line, + new_package, + # success callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues( + line, [{"package_id": new_package.id, "result_package_id": new_package.id}] + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + # check that reservations have been updated, the new package is not + # supposed to be in shelf2 anymore, and we should have no reserved qty + # for the initial package anymore + self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0) + self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line, lambda: line.reserved_qty, package=new_package + ) + + def test_change_pack_different_location_reserved_package(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.package_id, initial_package) + + # put a package in shelf2 in the system, but we assume that in real, + # the operator put it in shelf1 + new_package = self._create_package_in_location( + self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking2 = self._create_picking(lines=[(self.product_a, 10)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.package_id, new_package) + + # When the operator wants to pick the initial package, in shelf1, the new + # package is in front of the other so they want to change the package. + # The new package was supposed to be in shelf2 but is in fact in + # shelf1. + # An inventory must move it in shelf1 before we change the package on the line. + # Line2 must be unreserved and reserved again. + self.change_package_lot.change_package( + line, + new_package, + # success callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ), + # failure callback + self.unreachable_func, + ) + + # line2 has been re-created + line2 = picking2.move_line_ids + self.assertRecordValues( + line + line2, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "location_id": self.shelf1.id, + "reserved_qty": 10.0, + }, + { + "package_id": initial_package.id, + "result_package_id": initial_package.id, + "location_id": self.shelf1.id, + "reserved_qty": 10.0, + }, + ], + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + self.assertRecordValues( + line2.package_level_id, [{"package_id": initial_package.id}] + ) + # check that reservations have been updated, the new package is not + # supposed to be in shelf2 anymore, and we should have no reserved qty + # for the initial package anymore + self.assert_quant_package_qty(self.shelf2, new_package, lambda: 0) + self.assert_quant_reserved_qty( + line, lambda: line.reserved_qty, package=new_package + ) + self.assert_quant_reserved_qty( + line2, lambda: line2.reserved_qty, package=initial_package + ) + + def test_change_pack_different_location_reserved_package_qty_done(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.package_id, initial_package) + + # put a package in shelf2 in the system, but we assume that in real, + # the operator put it in shelf1 + new_package = self._create_package_in_location( + self.shelf2, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking2 = self._create_picking(lines=[(self.product_a, 10)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.package_id, new_package) + line2.qty_done = 10.0 + + # The new package was supposed to be in shelf2 but is in fact in shelf1. + # The package has already been picked in shelf2 (unlikely to happen... + # still we have to handle it). Forbid to pick. + expected_message = self.msg_store.package_change_error( + new_package, + "Package {} has been partially picked in another location".format( + new_package.display_name + ), + ) + self.change_package_lot.change_package( + line, + new_package, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual(message, expected_message), + ) + + # line2 has been re-created + line2 = picking2.move_line_ids + self.assertRecordValues( + line + line2, + [ + { + "package_id": initial_package.id, + "result_package_id": initial_package.id, + "location_id": self.shelf1.id, + "reserved_qty": 10.0, + }, + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "location_id": self.shelf2.id, + "reserved_qty": 10.0, + }, + ], + ) + # no change + self.assertRecordValues( + line.package_level_id, [{"package_id": initial_package.id}] + ) + self.assertRecordValues( + line2.package_level_id, [{"package_id": new_package.id}] + ) + self.assert_quant_package_qty(self.shelf2, new_package, lambda: 10.0) + self.assert_quant_reserved_qty( + line, lambda: line.reserved_qty, package=initial_package + ) + self.assert_quant_reserved_qty( + line2, lambda: line2.reserved_qty, package=new_package + ) + + def test_change_pack_lot_change_pack_less_qty_ok(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 100, lot=None)] + ) + + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + + self.assertRecordValues( + line, + [ + { + "package_id": initial_package.id, + # since we don't move the entire package (10 out of 100), no + # result package + "result_package_id": False, + "reserved_qty": 10.0, + } + ], + ) + self.assertFalse(line.package_level_id) + + # ensure we have our new package in the same location + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + self.change_package_lot.change_package( + line, + new_package, + # success callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ), + # failure callback + self.unreachable_func, + ) + self.assertRecordValues( + line, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "reserved_qty": 10.0, + } + ], + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + + # check that reservations have been updated + self.assert_quant_reserved_qty(line, lambda: 0, package=initial_package) + self.assert_quant_reserved_qty( + line, lambda: line.reserved_qty, package=new_package + ) + + def test_change_pack_steal_from_other_move_line(self): + """Exchange pack with another line + + When we scan the package used on another line not picked yet (qty_done + == 0), we unreserve the other line and use its package. The other line + is reserved again and should reserve the package used initially on our + move line. + """ + # create 2 picking, each with its own package + package1 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking1 = self._create_picking_with_package_level(package1) + self.assertEqual(picking1.move_line_ids.package_id, package1) + + package2 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking2 = self._create_picking_with_package_level(package2) + self.assertEqual(picking2.move_line_ids.package_id, package2) + + line = picking1.move_line_ids + + # We "steal" package2 for the picking1 + self.change_package_lot.change_package( + line, + package2, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.package_replaced_by_package(package1, package2) + ), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues( + picking1.move_line_ids, + [ + { + "package_id": package2.id, + "result_package_id": package2.id, + "state": "assigned", + "reserved_qty": 10.0, + } + ], + ) + self.assertRecordValues( + picking2.move_line_ids, + [ + { + "package_id": package1.id, + "result_package_id": package1.id, + "state": "assigned", + "reserved_qty": 10.0, + } + ], + ) + self.assertRecordValues( + picking1.package_level_ids, + [{"package_id": package2.id, "state": "assigned"}], + ) + self.assertRecordValues( + picking2.package_level_ids, + [{"package_id": package1.id, "state": "assigned"}], + ) + # check that reservations have been updated + self.assert_quant_reserved_qty( + picking1.move_line_ids, + lambda: picking1.move_line_ids.reserved_qty, + package=package2, + ) + self.assert_quant_reserved_qty( + picking2.move_line_ids, + lambda: picking2.move_line_ids.reserved_qty, + package=package1, + ) + + def test_other_line_with_qty_done(self): + """Try to exchange pack with other line with qty_done + + When we scan the package used on another line which has been picked + (qty_done > 0), do not unreserve the other line. + """ + # create 2 picking, each with its own package + package1 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking1 = self._create_picking_with_package_level(package1) + self.assertEqual(picking1.move_line_ids.package_id, package1) + + package2 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking2 = self._create_picking_with_package_level(package2) + self.assertEqual(picking2.move_line_ids.package_id, package2) + + line1 = picking1.move_line_ids + line2 = picking2.move_line_ids + line2.qty_done = 10 + + self.change_package_lot.change_package( + line1, + package2, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_change_error( + package2, + "Package {} does not contain available product {}," + " cannot replace package.".format( + package2.display_name, line1.product_id.display_name + ), + ), + ), + ) + + # did not change + self.assertRecordValues( + picking1.move_line_ids, + [ + { + "package_id": package1.id, + "result_package_id": package1.id, + "state": "assigned", + } + ], + ) + self.assertRecordValues( + picking2.move_line_ids, + [ + { + "package_id": package2.id, + "result_package_id": package2.id, + "state": "assigned", + } + ], + ) + self.assertRecordValues( + picking1.package_level_ids, + [{"package_id": package1.id, "state": "assigned"}], + ) + self.assertRecordValues( + picking2.package_level_ids, + [{"package_id": package2.id, "state": "assigned"}], + ) + # check that reservations have been updated + self.assert_quant_reserved_qty( + picking1.move_line_ids, + lambda: picking1.move_line_ids.reserved_qty, + package=package1, + ) + self.assert_quant_reserved_qty( + picking2.move_line_ids, + lambda: picking2.move_line_ids.reserved_qty, + package=package2, + ) + + def test_package_partial(self): + """Try to exchange pack with a package partially picked + + When we scan the package used on another line which has been picked + (qty_done > 0), but the new package still has unreserved quantity: + + * the current line is updated for the remaining unreserved quantity + * a new line is created for the remaining + * the other already picked line is untouched + """ + # create 2 picking, each with its own package + package1 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + picking1 = self._create_picking_with_package_level(package1) + line1 = picking1.move_line_ids + self.assertEqual(line1.package_id, package1) + + package2 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + # take partially in package2 (no package level as moving partial + # package) + picking2 = self._create_picking(lines=[(self.product_a, 8)]) + picking2.action_assign() + line2 = picking2.move_line_ids + self.assertEqual(line2.package_id, package2) + + # this line is picked, should not be changed, but we still have + # 2 units in package2 + line2.qty_done = line2.reserved_qty + + self.change_package_lot.change_package( + line1, + package2, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.package_replaced_by_package(package1, package2) + ), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues( + line1, + [ + { + "package_id": package2.id, + # not moved entirely by this transfer + "result_package_id": False, + "state": "assigned", + # as the remaining was 2 units, the line is + # changed to take only 2 + "reserved_qty": 2.0, + } + ], + ) + self.assertRecordValues( + # this line should be unchanged + line2, + [ + { + "package_id": package2.id, + # not moved entirely by this transfer + "result_package_id": False, + "state": "assigned", + "reserved_qty": 8.0, + } + ], + ) + + # A new line has been created for the quantity the line1 + # couldn't take in package2. It will take the first goods + # available, which happen to be package1 (which was unreserved + # when we changed the package of line1). + remaining_line = picking1.move_line_ids - line1 + self.assertRecordValues( + remaining_line, + [ + { + "package_id": package1.id, + # not moved entirely by this transfer + "result_package_id": False, + "state": "assigned", + # remaining qty for the 1st move + "reserved_qty": 8.0, + } + ], + ) + + # the package1 must have only 8 reserved, for the remaining + # of the line + self.assertEqual(package1.quant_ids.reserved_quantity, 8) + self.assertEqual(package2.quant_ids.reserved_quantity, 10) + + # no package is moved entirely at once + self.assertFalse(picking1.package_level_ids) + self.assertFalse(picking2.package_level_ids) + + def test_package_2_lines_1_move(self): + """Keep picked move line if we have 2 lines on a move + + Create a situation where we have 2 move lines on a move, with different + packages, 1 one of them is already picked (qty_done > 0), we change the + package on the second one: the first one must not be changed. + """ + package1 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 4, lot=None)] + ) + package2 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 8, lot=None)] + ) + + # take partially in package2 (no package level as moving partial + # package) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + move = picking.move_ids + line1, line2 = move.move_line_ids + self.assertEqual(line1.package_id, package1) + self.assertEqual(line2.package_id, package2) + + # package to switch to + package3 = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 8, lot=None)] + ) + + # this line is picked and must not be changed + line1.qty_done = line1.reserved_qty + + # as we change for package2, the line should get only the remaining + # part of the package + + self.change_package_lot.change_package( + line2, + package3, + # success callback + lambda move_line, message=None: self.assertEqual( + message, self.msg_store.package_replaced_by_package(package2, package3) + ), + # failure callback + self.unreachable_func, + ) + + self.assertRecordValues( + line1 | line2, + [ + { + "package_id": package1.id, + "state": "assigned", + "reserved_qty": 4.0, + "qty_done": 4.0, + }, + { + "package_id": package3.id, + "state": "assigned", + "reserved_qty": 6.0, + "qty_done": 0.0, + }, + ], + ) + + # package1 is moved entirely + self.assertTrue(line1.package_level_id) + # package2 is not moved entirely + self.assertFalse(line2.package_level_id) + + # the package1 must have only 8 reserved, for the remaining + # of the line + self.assertEqual(package1.quant_ids.reserved_quantity, 4) + self.assertEqual(package2.quant_ids.reserved_quantity, 0) + self.assertEqual(package3.quant_ids.reserved_quantity, 6) + + def test_change_pack_same(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 100, lot=None)] + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.action_assign() + line = picking.move_line_ids + self.assertEqual(line.package_id, initial_package) + self.change_package_lot.change_package( + line, + initial_package, + # success callback + self.unreachable_func, + # failure callback + lambda move_line, message=None: self.assertEqual( + message, + self.msg_store.package_change_error_same_package(initial_package), + ), + ) diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py new file mode 100644 index 0000000000..e46e2a6d11 --- /dev/null +++ b/shopfloor/tests/test_actions_data.py @@ -0,0 +1,376 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# pylint: disable=missing-return +from markupsafe import Markup + +from .common import PickingBatchMixin +from .test_actions_data_base import ActionsDataCaseBase + + +class ActionsDataCase(ActionsDataCaseBase): + def test_data_packaging(self): + data = self.data.packaging(self.packaging) + self.assert_schema(self.schema.packaging(), data) + self.assertDictEqual(data, self._expected_packaging(self.packaging)) + + def test_data_delivery_packaging(self): + data = self.data.delivery_packaging(self.delivery_packaging) + self.assert_schema(self.schema.delivery_packaging(), data) + self.assertDictEqual( + data, self._expected_delivery_packaging(self.delivery_packaging) + ) + + def test_data_location(self): + location = self.stock_location + data = self.data.location(location) + self.assert_schema(self.schema.location(), data) + expected = { + "id": location.id, + "name": location.name, + "barcode": location.barcode, + } + self.assertDictEqual(data, expected) + + def test_data_location_no_barcode(self): + location = self.stock_location + location.sudo().barcode = None + data = self.data.location(location) + self.assert_schema(self.schema.location(), data) + expected = { + "id": location.id, + "name": location.name, + "barcode": location.name, + } + self.assertDictEqual(data, expected) + + def test_data_location_with_operation_progress(self): + location = self.stock_location + location.sudo().barcode = None + data = self.data.location(location, with_operation_progress=True) + self.assert_schema(self.schema.location(), data) + expected = { + "id": location.id, + "name": location.name, + "barcode": location.name, + "operation_progress": { + "done": 2.0, + "to_do": 210.0, + }, + } + self.assertDictEqual(data, expected) + + def test_data_lot(self): + lot = self.env["stock.lot"].create( + { + "product_id": self.product_b.id, + "company_id": self.env.company.id, + "ref": "#FOO", + } + ) + data = self.data.lot(lot) + self.assert_schema(self.schema.lot(), data) + expected = { + "id": lot.id, + "name": lot.name, + "ref": "#FOO", + "expiration_date": None, + } + self.assertDictEqual(data, expected) + + def test_data_package(self): + package = self.move_a.move_line_ids.package_id + package.product_packaging_id = self.packaging.id + package.package_type_id = self.storage_type_pallet + data = self.data.package(package, picking=self.picking, with_packaging=True) + self.assert_schema(self.schema.package(with_packaging=True), data) + expected = { + "id": package.id, + "name": package.name, + "move_line_count": 1, + "packaging": self._expected_packaging(package.product_packaging_id), + "storage_type": self._expected_storage_type(package.package_type_id), + "weight": 20.0, + } + self.assertDictEqual(data, expected) + + def test_data_package_level(self): + package_level = self.picking.package_level_ids[0] + data = self.data.package_level(package_level) + self.assert_schema(self.schema.package_level(), data) + expected = { + "id": package_level.id, + "is_done": False, + "picking": self.picking.jsonify(["id", "name"])[0], + "package_src": self._expected_package(package_level.package_id), + "location_dest": self._expected_location(package_level.location_dest_id), + "location_src": self._expected_location( + package_level.picking_id.location_id + ), + "product": self._expected_product( + package_level.package_id.single_product_id + ), + "quantity": package_level.package_id.single_product_qty, + } + self.assertDictEqual(data, expected) + + def test_data_picking(self): + carrier = self.picking.carrier_id.search([], limit=1) + self.picking.write( + {"origin": "created by test", "note": "read me", "carrier_id": carrier.id} + ) + data = self.data.picking(self.picking) + self.assert_schema(self.schema.picking(), data) + expected = { + "id": self.picking.id, + "move_line_count": 4, + "package_level_count": 2, + "bulk_line_count": 2, + "name": self.picking.name, + "note": Markup("

read me

"), + "origin": "created by test", + "weight": 110.0, + "partner": {"id": self.customer.id, "name": self.customer.name}, + "carrier": {"id": carrier.id, "name": carrier.name}, + "ship_carrier": None, + } + self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-08-03") + self.assertDictEqual(data, expected) + + def test_data_picking_with_progress(self): + carrier = self.picking.carrier_id.search([], limit=1) + self.picking.write( + {"origin": "created by test", "note": "read me", "carrier_id": carrier.id} + ) + data = self.data.picking(self.picking, with_progress=True) + self.assert_schema(self.schema.picking(), data) + expected = { + "id": self.picking.id, + "move_line_count": 4, + "package_level_count": 2, + "bulk_line_count": 2, + "name": self.picking.name, + "note": Markup("

read me

"), + "origin": "created by test", + "weight": 110.0, + "partner": {"id": self.customer.id, "name": self.customer.name}, + "carrier": {"id": carrier.id, "name": carrier.name}, + "ship_carrier": None, + "progress": 0.0, + } + self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-08-03") + self.assertDictEqual(data, expected) + + def test_data_product(self): + ( + self.env["product.packaging"] + .sudo() + .create( + { + "name": "Box 2", + "product_id": self.product_a.id, + "barcode": "ProductABox2", + } + ) + ) + self.product_a.packaging_ids.write({"qty": 0}) + data = self.data.product(self.product_a) + self.assert_schema(self.schema.product(), data) + # No packaging expected as all qties are zero + expected = self._expected_product(self.product_a) + self.assertDictEqual(data, expected) + # packaging w/ no zero qty are included + self.product_a.packaging_ids[0].write({"qty": 100}) + self.product_a.packaging_ids[1].write({"qty": 20}) + data = self.data.product(self.product_a) + expected = self._expected_product(self.product_a) + self.assertDictEqual(data, expected) + + def test_data_move_line_package(self): + move_line = self.move_a.move_line_ids + result_package = self.env["stock.quant.package"].create( + {"product_packaging_id": self.packaging.id} + ) + move_line.write({"qty_done": 3.0, "result_package_id": result_package.id}) + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + self.assertIn(self.move_a.state, ["partially_available", "assigned", "done"]) + expected = { + "id": move_line.id, + "qty_done": 3.0, + "quantity": move_line.reserved_uom_qty, + "product": self._expected_product(self.product_a), + "lot": None, + "package_src": { + "id": move_line.package_id.id, + "name": move_line.package_id.name, + "move_line_count": 0, + "weight": 20.0, + "storage_type": None, + }, + "package_dest": { + "id": result_package.id, + "name": result_package.name, + "move_line_count": 1, + "weight": 6.0, + "storage_type": None, + }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", + "progress": 30.0, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_lot(self): + move_line = self.move_b.move_line_ids + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.reserved_uom_qty, + "product": self._expected_product(self.product_b), + "lot": { + "id": move_line.lot_id.id, + "name": move_line.lot_id.name, + "ref": None, + "expiration_date": None, + }, + "package_src": None, + "package_dest": None, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", + "progress": 0.0, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_package_lot(self): + move_line = self.move_c.move_line_ids + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + self.assertIn(self.move_a.state, ["partially_available", "assigned", "done"]) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.reserved_uom_qty, + "product": self._expected_product(self.product_c), + "lot": { + "id": move_line.lot_id.id, + "name": move_line.lot_id.name, + "ref": None, + "expiration_date": None, + }, + "package_src": { + "id": move_line.package_id.id, + "name": move_line.package_id.name, + "move_line_count": 1, + "weight": 30, + "storage_type": None, + }, + "package_dest": { + "id": move_line.result_package_id.id, + "name": move_line.result_package_id.name, + "move_line_count": 1, + "weight": 0, + "storage_type": None, + }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", + "progress": 0.0, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_raw(self): + move_line = self.move_d.move_line_ids + data = self.data.move_line(move_line) + self.assert_schema(self.schema.move_line(), data) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.reserved_uom_qty, + "product": self._expected_product(self.product_d), + "lot": None, + "package_src": None, + "package_dest": None, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", + "progress": 0.0, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_with_picking(self): + move_line = self.move_d.move_line_ids + data = self.data.move_line(move_line, with_picking=True) + self.assert_schema(self.schema.move_line(with_picking=True), data) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.reserved_uom_qty, + "product": self._expected_product(self.product_d), + "lot": None, + "package_src": None, + "package_dest": None, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "picking": self.data.picking(move_line.picking_id), + "priority": "1", + "progress": 0.0, + } + self.assertDictEqual(data, expected) + + +class ActionsDataCaseBatchPicking(ActionsDataCaseBase, PickingBatchMixin): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=20), + ], + [cls.BatchProduct(product=cls.product_a, quantity=30)], + ] + ) + + def test_data_picking_batch(self): + data = self.data.picking_batch(self.batch) + self.assert_schema(self.schema.picking_batch(), data) + # no assigned pickings + expected = { + "id": self.batch.id, + "name": self.batch.name, + "picking_count": 0, + "move_line_count": 0, + "weight": 0.0, + } + self.assertDictEqual(data, expected) + + self._simulate_batch_selected(self.batch, fill_stock=True) + expected.update( + { + "picking_count": 2, + "move_line_count": 3, + "weight": sum(self.batch.picking_ids.mapped("total_weight")), + } + ) + data = self.data.picking_batch(self.batch) + self.assertDictEqual(data, expected) + + def test_data_picking_batch_with_pickings(self): + self._simulate_batch_selected(self.batch, fill_stock=True) + data = self.data.picking_batch(self.batch, with_pickings=True) + self.assert_schema(self.schema.picking_batch(with_pickings=True), data) + # no assigned pickings + expected = { + "id": self.batch.id, + "name": self.batch.name, + "picking_count": 2, + "move_line_count": 3, + "weight": sum(self.batch.picking_ids.mapped("total_weight")), + "pickings": self.data.pickings(self.batch.picking_ids), + } + self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_actions_data_base.py b/shopfloor/tests/test_actions_data_base.py new file mode 100644 index 0000000000..ffbccb29d6 --- /dev/null +++ b/shopfloor/tests/test_actions_data_base.py @@ -0,0 +1,244 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.tools.float_utils import float_round + +from odoo.addons.shopfloor_base.tests.common_misc import ActionsDataTestMixin + +from .common import CommonCase + + +# pylint: disable=missing-return +class ActionsDataCaseBase(CommonCase, ActionsDataTestMixin): + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + cls.wh = cls.env.ref("stock.warehouse0") + cls.picking_type = cls.wh.out_type_id + cls.storage_type_pallet = cls.env.ref( + "stock_storage_type.package_storage_type_pallets" + ) + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.packaging_type = ( + cls.env["product.packaging.level"] + .sudo() + .create({"name": "Transport Box", "code": "TB", "sequence": 0}) + ) + cls.packaging = ( + cls.env["product.packaging"] + .sudo() + .create({"name": "Pallet", "packaging_level_id": cls.packaging_type.id}) + ) + cls.delivery_packaging = ( + cls.env["stock.package.type"] + .sudo() + .create( + { + "name": "Pallet", + "package_carrier_type": "none", + "barcode": "PALCODE", + } + ) + ) + cls.product_b.tracking = "lot" + cls.product_c.tracking = "lot" + cls.picking = cls._create_picking( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), + ] + ) + cls.picking.scheduled_date = "2020-08-03" + # put product A in a package + cls.move_a = cls.picking.move_ids[0] + cls._fill_stock_for_moves(cls.move_a, in_package=True) + # product B has a lot + cls.move_b = cls.picking.move_ids[1] + cls._fill_stock_for_moves(cls.move_b, in_lot=True) + # product C has a lot and package + cls.move_c = cls.picking.move_ids[2] + cls._fill_stock_for_moves(cls.move_c, in_package=True, in_lot=True) + # product D is raw + cls.move_d = cls.picking.move_ids[3] + cls._fill_stock_for_moves(cls.move_d) + (cls.move_a + cls.move_b + cls.move_c + cls.move_d).write({"priority": "1"}) + cls.picking.action_assign() + + cls.supplier = cls.env["res.partner"].sudo().create({"name": "Supplier"}) + cls.product_a_vendor = ( + cls.env["product.supplierinfo"] + .sudo() + .create( + { + "partner_id": cls.supplier.id, + "price": 8.0, + "product_code": "VENDOR_CODE_A", + "product_id": cls.product_a.id, + "product_tmpl_id": cls.product_a.product_tmpl_id.id, + } + ) + ) + cls.product_a_variant = cls.product_a.copy( + { + "name": "Product A variant 1", + "type": "product", + "default_code": "A-VARIANT", + "barcode": "A-VARIANT", + } + ) + # create another supplier info w/ lower sequence + cls.product_a_vendor = ( + cls.env["product.supplierinfo"] + .sudo() + .create( + { + "partner_id": cls.supplier.id, + "price": 12.0, + "product_code": "VENDOR_CODE_VARIANT", + "product_id": cls.product_a_variant.id, + "product_tmpl_id": cls.product_a.product_tmpl_id.id, + "sequence": 0, + } + ) + ) + cls.product_a_variant.flush_recordset() + cls.product_a_vendor.flush_recordset() + + def _expected_location(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + "barcode": record.barcode, + } + data.update(kw) + return data + + def _expected_product(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + "display_name": record.display_name, + "default_code": record.default_code, + "barcode": record.barcode, + "packaging": [ + self._expected_packaging(x) for x in record.packaging_ids if x.qty + ], + "uom": { + "factor": record.uom_id.factor, + "id": record.uom_id.id, + "name": record.uom_id.name, + "rounding": record.uom_id.rounding, + }, + "supplier_code": self._expected_supplier_code(record), + } + data.update(kw) + return data + + def _expected_supplier_code(self, product): + supplier_info = product.seller_ids.filtered(lambda x: x.product_id == product) + return supplier_info[0].product_code if supplier_info else "" + + def _expected_packaging(self, record, **kw): + data = { + "id": record.id, + "name": record.packaging_level_id.name, + "code": record.packaging_level_id.code, + "qty": record.qty, + } + data.update(kw) + return data + + def _expected_delivery_packaging(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + "packaging_type": record.package_carrier_type, + "barcode": record.barcode, + } + data.update(kw) + return data + + def _expected_storage_type(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + } + data.update(kw) + return data + + def _expected_package(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + "weight": record.pack_weight or record.estimated_pack_weight_kg, + "storage_type": None, + } + data.update(kw) + return data + + +class ActionsDataDetailCaseBase(ActionsDataCaseBase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.lot = cls.env["stock.lot"].create( + {"product_id": cls.product_b.id, "company_id": cls.env.company.id} + ) + cls.package = cls.move_a.move_line_ids.package_id + + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + cls.storage_type_pallet = cls.env.ref( + "stock_storage_type.package_storage_type_pallets" + ) + + def _expected_location_detail(self, record, **kw): + return dict( + **self._expected_location(record), + **{ + "complete_name": record.complete_name, + "reserved_move_lines": self.data_detail.move_lines( + kw.get("move_lines", []) + ), + } + ) + + def _expected_product_detail(self, record, **kw): + qty_available = record.qty_available + qty_reserved = float_round( + record.qty_available - record.free_qty, + precision_rounding=record.uom_id.rounding, + ) + detail = { + "qty_available": qty_available, + "qty_reserved": qty_reserved, + } + if kw.get("full"): + detail.update( + { + "image": "/web/image/product.product/{}/image_128".format(record.id) + if record.image_128 + else None, + "manufacturer": { + "id": record.manufacturer_id.id, + "name": record.manufacturer_id.name, + } + if record.manufacturer_id + else None, + "suppliers": [ + { + "id": v.partner_id.id, + "partner": v.partner_id.name, + "product_name": None, + "product_code": v.product_code, + } + for v in record.seller_ids + ], + } + ) + return dict(**self._expected_product(record), **detail) diff --git a/shopfloor/tests/test_actions_data_detail.py b/shopfloor/tests/test_actions_data_detail.py new file mode 100644 index 0000000000..0aa9740863 --- /dev/null +++ b/shopfloor/tests/test_actions_data_detail.py @@ -0,0 +1,322 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import base64 +import io + +from markupsafe import Markup +from PIL import Image + +from .test_actions_data_base import ActionsDataDetailCaseBase + + +def fake_colored_image(color="#4169E1", size=(800, 500)): + with io.BytesIO() as img_file: + Image.new("RGB", size, color).save(img_file, "JPEG") + img_file.seek(0) + return base64.b64encode(img_file.read()) + + +class TestActionsDataDetailCase(ActionsDataDetailCaseBase): + def test_data_location(self): + location = self.stock_location + data = self.data_detail.location_detail(location) + self.assert_schema(self.schema_detail.location_detail(), data) + move_lines = self.env["stock.move.line"].search( + [ + ("location_id", "child_of", location.id), + ("reserved_qty", ">", 0), + ("state", "not in", ("done", "cancel")), + ] + ) + self.assertDictEqual( + data, self._expected_location_detail(location, move_lines=move_lines) + ) + + def test_data_packaging(self): + data = self.data_detail.packaging(self.packaging) + self.assert_schema(self.schema_detail.packaging(), data) + self.assertDictEqual(data, self._expected_packaging(self.packaging)) + + def test_data_lot(self): + lot = self.env["stock.lot"].create( + { + "product_id": self.product_b.id, + "company_id": self.env.company.id, + "ref": "#FOO", + "removal_date": "2020-05-20", + "expiration_date": "2020-05-31", + } + ) + data = self.data_detail.lot_detail(lot) + self.assert_schema(self.schema_detail.lot_detail(), data) + + expected = { + "id": lot.id, + "name": lot.name, + "ref": "#FOO", + "expiration_date": "2020-05-31T00:00:00", + "product": self._expected_product_detail(self.product_b, full=True), + } + # ignore time and TZ, we don't care here + self.assertEqual(data.pop("removal_date").split("T")[0], "2020-05-20") + self.assertEqual(data.pop("expire_date").split("T")[0], "2020-05-31") + self.assertDictEqual(data, expected) + + def test_data_package(self): + package = self.move_a.move_line_ids.package_id + package.product_packaging_id = self.packaging.id + package.package_type_id = self.storage_type_pallet + # package.invalidate_recordset() + data = self.data_detail.package_detail(package, picking=self.picking) + self.assert_schema(self.schema_detail.package_detail(), data) + + lines = self.env["stock.move.line"].search( + [("package_id", "=", package.id), ("state", "not in", ("done", "cancel"))] + ) + pickings = lines.mapped("picking_id") + expected = { + "id": package.id, + "location": { + "id": package.location_id.id, + "name": package.location_id.display_name, + }, + "name": package.name, + "move_line_count": 1, + "packaging": self.data_detail.packaging(package.product_packaging_id), + "weight": 20.0, + "pickings": self.data_detail.pickings(pickings), + "move_lines": self.data_detail.move_lines(lines), + "storage_type": { + "id": self.storage_type_pallet.id, + "name": self.storage_type_pallet.name, + }, + } + self.assertDictEqual(data, expected) + + def test_data_picking(self): + picking = self.picking + carrier = picking.carrier_id.search([], limit=1) + picking.write( + { + "origin": "created by test", + "note": Markup("

read me

"), + "priority": "1", + "carrier_id": carrier.id, + } + ) + picking.move_ids.write({"date": "2020-05-13"}) + data = self.data_detail.picking_detail(picking) + self.assert_schema(self.schema_detail.picking_detail(), data) + expected = { + "id": picking.id, + "move_line_count": 4, + "package_level_count": 2, + "bulk_line_count": 2, + "name": picking.name, + "note": Markup("

read me

"), + "origin": "created by test", + "ship_carrier": None, + "weight": 110.0, + "partner": {"id": self.customer.id, "name": self.customer.name}, + "carrier": {"id": picking.carrier_id.id, "name": picking.carrier_id.name}, + "priority": "Urgent", + "operation_type": { + "id": picking.picking_type_id.id, + "name": picking.picking_type_id.name, + }, + "move_lines": self.data_detail.move_lines(picking.move_line_ids), + "picking_type_code": "outgoing", + } + self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13") + self.assertDictEqual(data, expected) + + def test_data_picking_with_progress(self): + picking = self.picking + carrier = picking.carrier_id.search([], limit=1) + picking.write( + { + "origin": "created by test", + "note": "read me", + "priority": "1", + "carrier_id": carrier.id, + } + ) + picking.move_ids.write({"date": "2020-05-13"}) + data = self.data_detail.picking_detail(picking, with_progress=True) + self.assert_schema(self.schema_detail.picking_detail(), data) + expected = { + "id": picking.id, + "move_line_count": 4, + "package_level_count": 2, + "bulk_line_count": 2, + "name": picking.name, + "note": Markup("

read me

"), + "origin": "created by test", + "ship_carrier": None, + "weight": 110.0, + "partner": {"id": self.customer.id, "name": self.customer.name}, + "carrier": {"id": picking.carrier_id.id, "name": picking.carrier_id.name}, + "priority": "Urgent", + "operation_type": { + "id": picking.picking_type_id.id, + "name": picking.picking_type_id.name, + }, + "move_lines": self.data_detail.move_lines(picking.move_line_ids), + "picking_type_code": "outgoing", + "progress": 0.0, + } + self.assertEqual(data.pop("scheduled_date").split("T")[0], "2020-05-13") + self.assertDictEqual(data, expected) + + def test_data_move_line_package(self): + move_line = self.move_a.move_line_ids + result_package = self.env["stock.quant.package"].create( + {"product_packaging_id": self.packaging.id} + ) + move_line.write({"qty_done": 3.0, "result_package_id": result_package.id}) + data = self.data_detail.move_line(move_line) + self.assert_schema(self.schema_detail.move_line(), data) + product = self.product_a.with_context(location=move_line.location_id.id) + expected = { + "id": move_line.id, + "qty_done": 3.0, + "quantity": move_line.reserved_uom_qty, + "product": self._expected_product_detail(product), + "lot": None, + "package_src": { + "id": move_line.package_id.id, + "name": move_line.package_id.name, + "move_line_count": 0, + "weight": 20.0, + "storage_type": None, + }, + "package_dest": { + "id": result_package.id, + "name": result_package.name, + "move_line_count": 1, + "weight": 6.0, + "storage_type": None, + }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", + "progress": 30.0, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_lot(self): + move_line = self.move_b.move_line_ids + data = self.data_detail.move_line(move_line) + self.assert_schema(self.schema_detail.move_line(), data) + product = self.product_b.with_context(location=move_line.location_id.id) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.reserved_uom_qty, + "product": self._expected_product_detail(product), + "lot": { + "id": move_line.lot_id.id, + "name": move_line.lot_id.name, + "ref": None, + "expiration_date": None, + }, + "package_src": None, + "package_dest": None, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", + "progress": 0.0, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_package_lot(self): + move_line = self.move_c.move_line_ids + data = self.data_detail.move_line(move_line) + self.assert_schema(self.schema_detail.move_line(), data) + product = self.product_c.with_context(location=move_line.location_id.id) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.reserved_uom_qty, + "product": self._expected_product_detail(product), + "lot": { + "id": move_line.lot_id.id, + "name": move_line.lot_id.name, + "ref": None, + "expiration_date": None, + }, + "package_src": { + "id": move_line.package_id.id, + "name": move_line.package_id.name, + "move_line_count": 1, + "weight": 30.0, + "storage_type": None, + }, + "package_dest": { + "id": move_line.result_package_id.id, + "name": move_line.result_package_id.name, + "move_line_count": 1, + "weight": 0.0, + "storage_type": None, + }, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", + "progress": 0.0, + } + self.assertDictEqual(data, expected) + + def test_data_move_line_raw(self): + move_line = self.move_d.move_line_ids + data = self.data_detail.move_line(move_line) + self.assert_schema(self.schema_detail.move_line(), data) + product = self.product_d.with_context(location=move_line.location_id.id) + expected = { + "id": move_line.id, + "qty_done": 0.0, + "quantity": move_line.reserved_uom_qty, + "product": self._expected_product_detail(product), + "lot": None, + "package_src": None, + "package_dest": None, + "location_src": self._expected_location(move_line.location_id), + "location_dest": self._expected_location(move_line.location_dest_id), + "priority": "1", + "progress": 0.0, + } + self.assertDictEqual(data, expected) + + def test_product(self): + move_line = self.move_b.move_line_ids + product = move_line.product_id.with_context(location=move_line.location_id.id) + Partner = self.env["res.partner"].sudo() + manuf = Partner.create({"name": "Manuf 1"}) + product.sudo().write( + { + "image_128": fake_colored_image(size=(128, 128)), + "manufacturer_id": manuf.id, + } + ) + vendor_a = Partner.create({"name": "Supplier A"}) + vendor_b = Partner.create({"name": "Supplier B"}) + SupplierInfo = self.env["product.supplierinfo"].sudo() + SupplierInfo.create( + { + "partner_id": vendor_a.id, + "product_tmpl_id": product.product_tmpl_id.id, + "product_id": product.id, + "product_code": "SUPP1", + } + ) + SupplierInfo.create( + { + "partner_id": vendor_b.id, + "product_tmpl_id": product.product_tmpl_id.id, + "product_id": product.id, + "product_code": "SUPP2", + } + ) + data = self.data_detail.product_detail(product) + self.assert_schema(self.schema_detail.product_detail(), data) + expected = self._expected_product_detail(product, full=True) + self.assertDictEqual(data, expected) diff --git a/shopfloor/tests/test_actions_search.py b/shopfloor/tests/test_actions_search.py new file mode 100644 index 0000000000..a804bbee10 --- /dev/null +++ b/shopfloor/tests/test_actions_search.py @@ -0,0 +1,248 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# @author Simone Orsi + +from .common import CommonCase + + +# pylint: disable=missing-return +class TestSearchBaseCase(CommonCase): + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + with cls.work_on_actions(cls) as work: + cls.search = work.component(usage="search") + + +class TestSearchCase(TestSearchBaseCase): + def test_search_location(self): + rec = self.customer_location + handler = self.search.location_from_scan + self.assertEqual(handler(rec.barcode), rec) + self.assertEqual(handler(rec.name), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + + def test_search_location_with_limit(self): + rec = self.customer_location + rec2 = self.customer_location.sudo().copy( + {"barcode": "CUSTOMERS2", "name": "Customers"} + ) + handler = self.search.location_from_scan + res = handler("Customers", 2) + self.assertEqual(res, rec + rec2) + + def test_search_package(self): + rec = self.env["stock.quant.package"].sudo().create({"name": "1234"}) + handler = self.search.package_from_scan + self.assertEqual(handler(rec.name), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + + def test_search_picking(self): + ptype = self.env.ref("shopfloor.picking_type_single_pallet_transfer_demo") + rec = self._create_picking(picking_type=ptype) + handler = self.search.picking_from_scan + self.assertEqual(handler(rec.name), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + + def test_search_product(self): + rec = self.product_a + handler = self.search.product_from_scan + self.assertEqual(handler(rec.barcode), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + # It is not possible to search a product by packaging + packaging = self.product_a_packaging + self.assertFalse(handler(packaging.barcode)) + + def test_search_lot_number_unique(self): + rec = ( + self.env["stock.lot"] + .sudo() + .create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + ) + handler = self.search.lot_from_scan + self.assertEqual(handler(rec.name, products=self.product_a), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + + def test_search_lot_number_shared_with_multiple_products(self): + lot_model = self.env["stock.lot"].sudo() + lots = ( + lot_model.create( + { + "name": "TEST", + "product_id": self.product_a.id, + "company_id": self.env.company.id, + } + ), + lot_model.create( + { + "name": "TEST", + "product_id": self.product_b.id, + "company_id": self.env.company.id, + } + ), + ) + handler = self.search.lot_from_scan + self.assertEqual(handler(lots[0].name, products=self.product_a), lots[0]) + self.assertEqual(handler(lots[1].name, products=self.product_a), lots[0]) + self.assertEqual(handler(lots[0].name, products=self.product_b), lots[1]) + self.assertEqual(handler(lots[1].name, products=self.product_b), lots[1]) + + def test_search_generic_packaging(self): + rec = ( + self.env["product.packaging"] + .sudo() + .create({"name": "TEST PKG", "barcode": "1234"}) + ) + handler = self.search.generic_packaging_from_scan + self.assertEqual(handler(rec.barcode), rec) + self.assertEqual(handler(False), rec.browse()) + self.assertEqual(handler("NONE"), rec.browse()) + + +class TestFindCase(TestSearchBaseCase): + def test_find_api(self): + self.assertEqual(self.search.find(False).record, None) + self.assertEqual(self.search.find("NONE").record, None) + self.assertEqual(self.search.find("foo", types=("not_existing",)).record, None) + # TODO: test SearchResult class + + def test_find_location(self): + rec = self.customer_location + res = self.search.find(rec.barcode, types=("location",)) + self.assertEqual(res.record, rec) + res = self.search.find(rec.name, types=("location",)) + self.assertEqual(res.record, rec) + + def test_find_location_with_limit(self): + rec = self.customer_location + rec2 = self.customer_location.sudo().copy( + {"barcode": "CUSTOMERS2", "name": "Customers"} + ) + res = self.search.find("Customers", types=("location",)) + self.assertEqual(res.records, None) + res = self.search.find( + "Customers", types=("location",), handler_kw=dict(location=dict(limit=2)) + ) + self.assertEqual(res.records, rec + rec2) + + def test_find_package(self): + rec = self.env["stock.quant.package"].sudo().create({"name": "1234"}) + res = self.search.find(rec.name, types=("package",)) + self.assertEqual(res.record, rec) + + def test_find_picking(self): + ptype = self.env.ref("shopfloor.picking_type_single_pallet_transfer_demo") + rec = self._create_picking(picking_type=ptype) + res = self.search.find(rec.name, types=("picking",)) + self.assertEqual(res.record, rec) + + def test_find_product(self): + rec = self.product_a + res = self.search.find(rec.barcode, types=("product",)) + self.assertEqual(res.record, rec) + # It is not possible to search a product by packaging + packaging = self.product_a_packaging + res = self.search.find(packaging.barcode, types=("product",)) + self.assertEqual(res.record, None) + + def test_find_product_packaging(self): + rec = self.product_a_packaging + res = self.search.find(rec.barcode, types=("packaging",)) + self.assertEqual(res.record, rec) + + def test_find_lot_number_unique(self): + rec = ( + self.env["stock.lot"] + .sudo() + .create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + ) + res = self.search.find( + rec.name, types=("lot",), handler_kw=dict(lot=dict(products=self.product_a)) + ) + self.assertEqual(res.record, rec) + + def test_find_lot_number_shared_with_multiple_products(self): + lot_model = self.env["stock.lot"].sudo() + lots = ( + lot_model.create( + { + "name": "TEST", + "product_id": self.product_a.id, + "company_id": self.env.company.id, + } + ), + lot_model.create( + { + "name": "TEST", + "product_id": self.product_b.id, + "company_id": self.env.company.id, + } + ), + ) + res = self.search.find( + lots[0].name, + types=("lot",), + handler_kw=dict(lot=dict(products=self.product_a)), + ) + self.assertEqual(res.record, lots[0]) + res = self.search.find( + lots[1].name, + types=("lot",), + handler_kw=dict(lot=dict(products=self.product_a)), + ) + self.assertEqual(res.record, lots[0]) + res = self.search.find( + lots[0].name, + types=("lot",), + handler_kw=dict(lot=dict(products=self.product_b)), + ) + self.assertEqual(res.record, lots[1]) + res = self.search.find( + lots[1].name, + types=("lot",), + handler_kw=dict(lot=dict(products=self.product_b)), + ) + self.assertEqual(res.record, lots[1]) + + def test_find_chain(self): + prod = self.product_a + # prod last type -> found + res = self.search.find( + prod.barcode, + types=( + "location", + "lot", + "product", + ), + ) + self.assertEqual(res.record, prod) + loc = self.customer_location.sudo().copy({"barcode": prod.barcode}) + # prod last type but a location w/ the same name exists -> location found + res = self.search.find( + prod.barcode, + types=( + "location", + "lot", + "product", + ), + ) + self.assertEqual(res.record, loc) + # change types order -> prod found + res = self.search.find( + prod.barcode, + types=( + "product", + "location", + "lot", + ), + ) + self.assertEqual(res.record, prod) diff --git a/shopfloor/tests/test_actions_stock.py b/shopfloor/tests/test_actions_stock.py new file mode 100644 index 0000000000..00524458c2 --- /dev/null +++ b/shopfloor/tests/test_actions_stock.py @@ -0,0 +1,48 @@ +# Copyright 2023 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# from odoo.tests.common import Form + +from .common import CommonCase + + +# pylint: disable=missing-return +class TestActionsStock(CommonCase): + """Tests covering methods to work on stock operations.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + with cls.work_on_actions(cls) as work: + cls.stock = work.component(usage="stock") + cls.picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)], confirm=True + ) + cls.move0 = cls.picking.move_ids[0] + cls.move1 = cls.picking.move_ids[1] + cls._fill_stock_for_moves(cls.move0) + cls._fill_stock_for_moves(cls.move1) + cls.picking.action_assign() + + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + cls.wh = cls.env.ref("stock.warehouse0") + cls.picking_type = cls.wh.out_type_id + + def test_unmark_move_line_as_picked(self): + """Check unmarking line as picked works.""" + lines_picked = self.picking.move_line_ids + # all lines (two) are picked + self.stock.mark_move_line_as_picked(lines_picked) + self.assertTrue(self.picking.user_id) + # unpick one line + line_unpicked = lines_picked[0] + self.stock.unmark_move_line_as_picked(line_unpicked) + # because not all lines of the picking have to be unpicked + # they should be split to a new picking + picking_not_assigned = line_unpicked.picking_id + self.assertTrue(line_unpicked.picking_id != lines_picked.picking_id) + self.assertTrue(self.picking.user_id) + self.assertTrue(self.picking.move_line_ids.shopfloor_user_id) + self.assertFalse(picking_not_assigned.move_line_ids.shopfloor_user_id) + self.assertFalse(picking_not_assigned.user_id) diff --git a/shopfloor/tests/test_checkout_auto_post.py b/shopfloor/tests/test_checkout_auto_post.py new file mode 100644 index 0000000000..79bb1473b8 --- /dev/null +++ b/shopfloor/tests/test_checkout_auto_post.py @@ -0,0 +1,67 @@ +# Copyright 2023 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_checkout_base import CheckoutCommonCase + + +class CheckoutAutoPostCase(CheckoutCommonCase): + def test_auto_posting(self): + self.menu.sudo().auto_post_line = True + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 20), (self.product_c, 30)] + ) + self._fill_stock_for_moves(picking.move_ids) + picking.action_assign() + selected_move_line_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line_a.qty_done = 7 + selected_move_line_b = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_b + ) + selected_move_line_b.qty_done = 9 + selected_move_line_c = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_c + ) + + # User has selected 7 units out of 10 for product_a, + # and 9 units out of 20 for product_b. + # We would expect a split picking to be created with those two lines and qtys done. + self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": [selected_move_line_a.id, selected_move_line_b.id], + "barcode": self.delivery_packaging.barcode, + }, + ) + + # Check that two new lines for products a and b are in a split picking, + # and the line for product c remained in the original picking. + self.assertNotEqual(picking, selected_move_line_a.picking_id) + self.assertEqual( + selected_move_line_a.picking_id, selected_move_line_b.picking_id + ) + self.assertEqual(picking, selected_move_line_c.picking_id) + + # The lines in the new picking must have the expected qty_done, + # and the split picking must be marked as "done". + self.assertEqual(selected_move_line_a.qty_done, 7) + self.assertEqual(selected_move_line_b.qty_done, 9) + self.assertEqual(selected_move_line_a.picking_id.state, "done") + + # In the original picking, we should have three lines: + # - the original line for product c, unchanged; + # - two lines (products a and b) with the non-split qtys. + line_a_in_original_picking = picking.move_line_ids.filtered( + lambda l: l.product_id == selected_move_line_a.product_id + ) + line_b_in_original_picking = picking.move_line_ids.filtered( + lambda l: l.product_id == selected_move_line_b.product_id + ) + self.assertEqual(line_a_in_original_picking.reserved_uom_qty, 3) + self.assertEqual(line_b_in_original_picking.reserved_uom_qty, 11) + self.assertEqual(selected_move_line_c.reserved_uom_qty, 30) + + self.assertEqual(line_a_in_original_picking.qty_done, 0) + self.assertEqual(line_b_in_original_picking.qty_done, 0) + self.assertEqual(selected_move_line_c.qty_done, 0) diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py new file mode 100644 index 0000000000..54be909e25 --- /dev/null +++ b/shopfloor/tests/test_checkout_base.py @@ -0,0 +1,81 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .common import CommonCase + + +# pylint: disable=missing-return +class CheckoutCommonCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_demo_checkout") + cls.profile = cls.env.ref("shopfloor.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.wh.sudo().delivery_steps = "pick_pack_ship" + cls.delivery_packaging = ( + cls.env["stock.package.type"] + .sudo() + .create( + { + "name": "Pallet", + "package_carrier_type": "none", + "barcode": "PALCODE", + } + ) + ) + + def setUp(self): + super().setUp() + self.service = self.get_service( + "checkout", menu=self.menu, profile=self.profile + ) + + def _stock_picking_data(self, picking, **kw): + return self.service._data_for_stock_picking(picking, **kw) + + # we test the methods that structure data in test_actions_data.py + def _picking_summary_data(self, picking): + return self.data.picking(picking) + + def _move_line_data(self, move_line): + return self.data.move_line(move_line) + + def _package_data(self, package, picking): + return self.data.package(package, picking=picking, with_packaging=True) + + def _packaging_data(self, packaging): + return self.data.delivery_packaging(packaging) + + def _data_for_select_line(self, picking, **kw): + data = { + "picking": self._stock_picking_data(picking), + "group_lines_by_location": True, + "show_oneline_package_content": False, + "need_confirm_pack_all": False, + } + data.update(kw) + return data + + def _assert_select_package_qty_above(self, response, picking): + self.assert_response( + response, + next_state="select_package", + data={ + "selected_move_lines": [ + self._move_line_data(ml) for ml in picking.move_line_ids.sorted() + ], + "picking": self._picking_summary_data(picking), + "packing_info": "", + "no_package_enabled": True, + }, + message={ + "message_type": "warning", + "body": "The quantity scanned for one or more lines cannot be " + "higher than the maximum allowed.", + }, + ) diff --git a/shopfloor/tests/test_checkout_cancel_line.py b/shopfloor/tests/test_checkout_cancel_line.py new file mode 100644 index 0000000000..4bebb38aed --- /dev/null +++ b/shopfloor/tests/test_checkout_cancel_line.py @@ -0,0 +1,154 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_checkout_base import CheckoutCommonCase + + +# pylint: disable=missing-return +class CheckoutRemovePackageCase(CheckoutCommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), + ] + ) + cls.pack1_moves = picking.move_ids[:2] + cls.pack2_moves = picking.move_ids[2] + cls.raw_move = picking.move_ids[3] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.pack2_moves, in_package=True) + cls._fill_stock_for_moves(cls.raw_move) + picking.action_assign() + + def test_cancel_package_ok(self): + picking = self.picking + + pack1_lines = self.pack1_moves.move_line_ids + pack2_lines = self.pack2_moves.move_line_ids + raw_line = self.raw_move.move_line_ids + + # do as we packed the lines in 2 different packages + new_package = self.env["stock.quant.package"].create({}) + (pack1_lines | raw_line).write( + { + "qty_done": 10, + "result_package_id": new_package.id, + "shopfloor_checkout_done": True, + } + ) + new_package2 = self.env["stock.quant.package"].create({}) + pack2_lines.write( + { + "qty_done": 10, + "result_package_id": new_package2.id, + "shopfloor_checkout_done": True, + } + ) + + # and now, we want to drop the new_package + response = self.service.dispatch( + "cancel_line", + params={"picking_id": picking.id, "package_id": new_package.id}, + ) + + self.assertRecordValues( + pack1_lines + raw_line + pack2_lines, + [ + { + "qty_done": 0, + # reset to origin package + "result_package_id": pack1_lines.mapped("package_id").id, + "shopfloor_checkout_done": False, + }, + { + "qty_done": 0, + # reset to origin package + "result_package_id": pack1_lines.mapped("package_id").id, + "shopfloor_checkout_done": False, + }, + { + "qty_done": 0, + # result to an empty package (raw product) + "result_package_id": False, + "shopfloor_checkout_done": False, + }, + # different package, leave untouched + { + "qty_done": 10, + "result_package_id": new_package2.id, + "shopfloor_checkout_done": True, + }, + ], + ) + + self.assert_response( + response, + next_state="select_line", + data=self._data_for_select_line(picking), + message={"body": "Package cancelled", "message_type": "success"}, + ) + + def test_cancel_line_ok(self): + picking = self.picking + + raw_line = self.raw_move.move_line_ids + + raw_line.write({"qty_done": 10, "shopfloor_checkout_done": True}) + + # and now, we want to drop the new_package + response = self.service.dispatch( + "cancel_line", + params={"picking_id": picking.id, "line_id": raw_line.id}, + ) + + self.assertRecordValues( + raw_line, + [{"qty_done": 0, "shopfloor_checkout_done": False}], + ) + + self.assert_response( + response, + next_state="select_line", + data=self._data_for_select_line(picking), + message={"body": "Line cancelled", "message_type": "success"}, + ) + + def test_cancel_line_error_package_not_found(self): + # and now, we want to drop the new_package + response = self.service.dispatch( + "cancel_line", params={"picking_id": self.picking.id, "package_id": 0} + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, + message={ + "message_type": "error", + "body": "The record you were working on does not exist anymore.", + }, + ) + + def test_cancel_line_error_line_not_found(self): + # and now, we want to drop the new_package + response = self.service.dispatch( + "cancel_line", params={"picking_id": self.picking.id, "line_id": 0} + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, + message={ + "message_type": "error", + "body": "The record you were working on does not exist anymore.", + }, + ) diff --git a/shopfloor/tests/test_checkout_change_packaging.py b/shopfloor/tests/test_checkout_change_packaging.py new file mode 100644 index 0000000000..8d0bf907f0 --- /dev/null +++ b/shopfloor/tests/test_checkout_change_packaging.py @@ -0,0 +1,184 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_checkout_base import CheckoutCommonCase + + +# pylint: disable=missing-return +class CheckoutListSetPackagingCase(CheckoutCommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.env["stock.package.type"].sudo().search([]).active = False + pallet_type = ( + cls.env["product.packaging.level"] + .sudo() + .create({"name": "Pallet", "code": "P", "sequence": 3}) + ) + cls.packaging_pallet = ( + cls.env["stock.package.type"] + .sudo() + .create( + { + "packaging_level_id": pallet_type.id, + "name": "Pallet", + "barcode": "PPP", + "height": 100, + "width": 100, + "packaging_length": 100, + "sequence": 2, + } + ) + ) + box_type = ( + cls.env["product.packaging.level"] + .sudo() + .create({"name": "Box", "code": "B", "sequence": 2}) + ) + cls.packaging_box = ( + cls.env["stock.package.type"] + .sudo() + .create( + { + "packaging_level_id": box_type.id, + "name": "Box", + "barcode": "BBB", + "height": 20, + "width": 20, + "packaging_length": 20, + "sequence": 1, + } + ) + ) + inner_box_type = ( + cls.env["product.packaging.level"] + .sudo() + .create({"name": "Inner Box", "code": "I", "sequence": 1}) + ) + cls.packaging_inner_box = ( + cls.env["stock.package.type"] + .sudo() + .create( + { + "packaging_level_id": inner_box_type.id, + "name": "Inner Box", + "barcode": "III", + "height": 10, + "width": 10, + "packaging_length": 10, + "sequence": 0, + } + ) + ) + cls.picking = cls._create_picking(lines=[(cls.product_a, 10)]) + cls._fill_stock_for_moves(cls.picking.move_ids, in_package=True) + cls.picking.action_assign() + cls.package = cls.picking.move_line_ids.result_package_id + cls.package.package_type_id = cls.packaging_pallet + cls.packagings = cls.env["stock.package.type"].search([]).sorted() + + def test_list_packaging_ok(self): + response = self.service.dispatch( + "list_packaging", + params={"picking_id": self.picking.id, "package_id": self.package.id}, + ) + + self.assert_response( + response, + next_state="change_packaging", + data={ + "picking": self._picking_summary_data(self.picking), + "package": self._package_data(self.package, self.picking), + "packaging": [ + self._packaging_data(packaging) + for packaging in self.packaging_inner_box + + self.packaging_box + + self.packaging_pallet + ], + }, + ) + + def test_list_packaging_error_package_not_found(self): + response = self.service.dispatch( + "list_packaging", params={"picking_id": self.picking.id, "package_id": 0} + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, + message={ + "message_type": "error", + "body": "The record you were working on does not exist anymore.", + }, + ) + + def test_set_packaging_ok(self): + response = self.service.dispatch( + "set_packaging", + params={ + "picking_id": self.picking.id, + "package_id": self.package.id, + "package_type_id": self.packaging_inner_box.id, + }, + ) + self.assertRecordValues( + self.package, [{"package_type_id": self.packaging_inner_box.id}] + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, + message={ + "message_type": "success", + "body": "Packaging changed on package {}".format(self.package.name), + }, + ) + + def test_set_packaging_error_package_not_found(self): + response = self.service.dispatch( + "set_packaging", + params={ + "picking_id": self.picking.id, + "package_id": 0, + "package_type_id": self.packaging_inner_box.id, + }, + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, + message={ + "message_type": "error", + "body": "The record you were working on does not exist anymore.", + }, + ) + + def test_set_packaging_error_packaging_not_found(self): + response = self.service.dispatch( + "set_packaging", + params={ + "picking_id": self.picking.id, + "package_id": self.package.id, + "package_type_id": 0, + }, + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, + message={ + "message_type": "error", + "body": "The record you were working on does not exist anymore.", + }, + ) diff --git a/shopfloor/tests/test_checkout_done.py b/shopfloor/tests/test_checkout_done.py new file mode 100644 index 0000000000..930010eb02 --- /dev/null +++ b/shopfloor/tests/test_checkout_done.py @@ -0,0 +1,133 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_checkout_base import CheckoutCommonCase + + +# pylint: disable=missing-return +class CheckoutDoneCase(CheckoutCommonCase): + def test_done_ok(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + # line is done + picking.move_line_ids.write({"qty_done": 10, "shopfloor_checkout_done": True}) + response = self.service.dispatch("done", params={"picking_id": picking.id}) + + self.assertRecordValues(picking, [{"state": "done"}]) + + self.assert_response( + response, + next_state="select_document", + message={ + "message_type": "success", + "body": "Transfer {} done".format(picking.name), + }, + ) + + +class CheckoutDonePartialCase(CheckoutCommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls._fill_stock_for_moves(picking.move_ids) + picking.action_assign() + cls.line1 = picking.move_line_ids[0] + cls.line2 = picking.move_line_ids[1] + cls.line1.write({"qty_done": 10, "shopfloor_checkout_done": True}) + cls.line2.write({"qty_done": 2, "shopfloor_checkout_done": True}) + + def test_done_partial(self): + # line is done + response = self.service.dispatch("done", params={"picking_id": self.picking.id}) + + self.assertRecordValues(self.picking, [{"state": "assigned"}]) + + self.assert_response( + response, + next_state="confirm_done", + data={"picking": self._stock_picking_data(self.picking, done=True)}, + message=self.service.msg_store.transfer_confirm_done(), + ) + + def test_done_partial_confirm(self): + # lines are done + response = self.service.dispatch( + "done", params={"picking_id": self.picking.id, "confirmation": True} + ) + # as they are all the lines that relate to the picking, they didn't have + # been extracted in a separate transfer. An usual backorder has been + # created for the unprocessed qty. + self.assertRecordValues(self.picking, [{"state": "done"}]) + self.assertTrue(self.picking.backorder_ids) + self.assertEqual(self.picking.backorder_ids.move_line_ids.reserved_uom_qty, 8) + + self.assert_response( + response, + next_state="select_document", + message=self.service.msg_store.transfer_done_success(self.picking), + ) + + +class CheckoutDoneRawUnpackedCase(CheckoutCommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls._fill_stock_for_moves(picking.move_ids) + picking.action_assign() + cls.line1 = picking.move_line_ids[0] + cls.line2 = picking.move_line_ids[1] + cls.package = cls.env["stock.quant.package"].create({}) + cls.line1.write( + { + "qty_done": 10, + "shopfloor_checkout_done": True, + "result_package_id": cls.package.id, + } + ) + cls.line2.write({"qty_done": 10, "shopfloor_checkout_done": False}) + + def test_done_partial(self): + # line is done + response = self.service.dispatch("done", params={"picking_id": self.picking.id}) + + self.assertRecordValues(self.picking, [{"state": "assigned"}]) + + self.assert_response( + response, + next_state="confirm_done", + data={"picking": self._stock_picking_data(self.picking, done=True)}, + message={ + "message_type": "warning", + "body": "Remaining raw product not packed, proceed anyway?", + }, + ) + + def test_done_partial_confirm(self): + # line is done + response = self.service.dispatch( + "done", params={"picking_id": self.picking.id, "confirmation": True} + ) + + # it has been extracted in its own picking, the current one staying open + picking_done = self.line1.picking_id + self.assertRecordValues(picking_done, [{"state": "done", "backorder_ids": []}]) + self.assertRecordValues( + self.picking, [{"state": "assigned", "backorder_ids": [picking_done.id]}] + ) + self.assertRecordValues( + self.line1 + self.line2, + [{"result_package_id": self.package.id}, {"result_package_id": False}], + ) + self.assertIn(self.line2, self.picking.move_line_ids) + + self.assert_response( + response, + next_state="select_document", + message=self.service.msg_store.transfer_done_success(picking_done), + ) diff --git a/shopfloor/tests/test_checkout_list_delivery_packaging.py b/shopfloor/tests/test_checkout_list_delivery_packaging.py new file mode 100644 index 0000000000..6bd2cbfd9b --- /dev/null +++ b/shopfloor/tests/test_checkout_list_delivery_packaging.py @@ -0,0 +1,131 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo_test_helper import FakeModelLoader + +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +# pylint: disable=missing-return +class CheckoutListDeliveryPackagingCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + @classmethod + def setUpClass(cls): + try: + super().setUpClass() + except BaseException: + # ensure that the registry is restored in case of error in setUpClass + # since tearDownClass is not called in this case and our _load_test_models + # loads fake models + if hasattr(cls, "loader"): + cls.loader.restore_registry() + raise + + @classmethod + def _load_test_models(cls): + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .models import DeliveryCarrierTest, StockPackageType + + cls.loader.update_registry((DeliveryCarrierTest, StockPackageType)) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super(CheckoutListDeliveryPackagingCase, cls).tearDownClass() + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls._load_test_models() + cls.carrier = cls.env["delivery.carrier"].search([], limit=1) + cls.carrier.sudo().delivery_type = "test" + cls.picking = cls._create_picking( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), + ] + ) + cls.picking.carrier_id = cls.carrier + cls.packaging_type = ( + cls.env["product.packaging.level"] + .sudo() + .create({"name": "Transport Box", "code": "TB", "sequence": 0}) + ) + cls.delivery_packaging1 = ( + cls.env["stock.package.type"] + .sudo() + .create( + { + "name": "Box 1", + "package_carrier_type": "test", + "barcode": "BOX1", + } + ) + ) + cls.delivery_packaging2 = ( + cls.env["stock.package.type"] + .sudo() + .create( + { + "name": "Box 2", + "package_carrier_type": "test", + "barcode": "BOX2", + } + ) + ) + cls.delivery_packaging = ( + cls.delivery_packaging1 | cls.delivery_packaging2 + ).sorted("name") + + def test_list_delivery_packaging_available(self): + self._fill_stock_for_moves(self.picking.move_ids, in_package=True) + self.picking.action_assign() + selected_lines = self.picking.move_line_ids + response = self.service.dispatch( + "list_delivery_packaging", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + }, + ) + self.assert_response( + response, + next_state="select_delivery_packaging", + data={ + "packaging": self.service.data.delivery_packaging_list( + self.delivery_packaging + ), + }, + ) + + def test_list_delivery_packaging_not_available(self): + self.delivery_packaging.package_carrier_type = False + self._fill_stock_for_moves(self.picking.move_ids, in_package=True) + self.picking.action_assign() + selected_lines = self.picking.move_line_ids + # for line in selected_lines: + # line.qty_done = line.reserved_uom_qty + response = self.service.dispatch( + "list_delivery_packaging", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + }, + ) + self.assert_response( + response, + next_state="select_package", + data={ + "picking": self._picking_summary_data(self.picking), + "selected_move_lines": [ + self._move_line_data(ml) for ml in selected_lines.sorted() + ], + "packing_info": self.service._data_for_packing_info(self.picking), + "no_package_enabled": not self.service.options.get( + "checkout__disable_no_package" + ), + }, + message=self.service.msg_store.no_delivery_packaging_available(), + ) diff --git a/shopfloor/tests/test_checkout_list_package.py b/shopfloor/tests/test_checkout_list_package.py new file mode 100644 index 0000000000..1e0556c5a2 --- /dev/null +++ b/shopfloor/tests/test_checkout_list_package.py @@ -0,0 +1,327 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields + +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + +# pylint: disable=missing-return + + +class SelectDestPackageMixin: + def _assert_response_select_dest_package( + self, response, picking, selected_lines, packages, message=None + ): + picking_data = self.data.picking(picking) + picking_data.update( + { + "note": None, + "origin": None, + "weight": 110.0, + "move_line_count": len(picking.move_line_ids), + } + ) + self.assert_response( + response, + next_state="select_dest_package", + data={ + "picking": picking_data, + "packages": [ + self._package_data( + package.with_context(picking_id=picking.id), picking + ) + for package in packages + ], + "selected_move_lines": [ + self._move_line_data(ml) for ml in selected_lines.sorted() + ], + }, + message=message, + ) + + +class CheckoutListDestPackageCase( + CheckoutCommonCase, CheckoutSelectPackageMixin, SelectDestPackageMixin +): + def test_list_dest_package_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + self._fill_stock_for_moves(picking.move_ids[:2], in_package=True) + self._fill_stock_for_moves(picking.move_ids[2], in_package=True) + self._fill_stock_for_moves(picking.move_ids[3], in_package=True) + picking.action_assign() + delivery_packaging = self.env.ref( + "stock_storage_type.product_product_9_packaging_single_bag" + ) + delivery_package = self.env["stock.quant.package"].create( + {"package_type_id": delivery_packaging.id} + ) + picking.move_ids[1].move_line_ids.result_package_id = delivery_package + response = self.service.dispatch( + "list_dest_package", + params={ + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + }, + ) + self._assert_response_select_dest_package( + response, picking, picking.move_line_ids, delivery_package + ) + + def test_list_dest_package_error_no_package(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + self._fill_stock_for_moves(picking.move_ids) + picking.action_assign() + self.assertEqual(picking.state, "assigned") + response = self.service.dispatch( + "list_dest_package", + params={ + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + }, + ) + self._assert_selected_response( + response, + picking.move_line_ids, + message={"message_type": "warning", "body": "No valid package to select."}, + ) + + +class CheckoutScanSetDestPackageCase(CheckoutCommonCase, SelectDestPackageMixin): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + picking = cls._create_picking( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), + ] + ) + pack1_moves = picking.move_ids[:3] + pack2_moves = picking.move_ids[3:] + # put in 2 packs, for this test, we'll work on pack1 + cls._fill_stock_for_moves(pack1_moves, in_package=True) + cls._fill_stock_for_moves(pack2_moves, in_package=True) + picking.action_assign() + + cls.selected_lines = pack1_moves.move_line_ids + cls.pack1 = pack1_moves.move_line_ids.package_id + cls.delivery_packaging = cls.env.ref( + "stock_storage_type.product_product_9_packaging_single_bag" + ) + cls.delivery_package = cls.env["stock.quant.package"].create( + {"package_type_id": cls.delivery_packaging.id} + ) + cls.move_line1, cls.move_line2, cls.move_line3 = cls.selected_lines + # The 'scan_dest_package' and 'set_dest_package' methods can not be + # used at all if there is no valid delivery package on the picking + # (the user is redirected to the 'select_package' step in that case), + # so we need at least to set one to pass this check in order to test + # them + cls.move_line1.result_package_id = cls.delivery_package + # We'll put only product A and B in the destination package + cls.move_line1.qty_done = cls.move_line1.reserved_uom_qty + cls.move_line2.qty_done = cls.move_line2.reserved_uom_qty + cls.move_line3.qty_done = 0 + + cls.picking = picking + + def _get_allowed_packages(self, picking): + return ( + picking.mapped("move_line_ids.package_id") + | picking.mapped("move_line_ids.result_package_id") + ).filtered("package_type_id") + + def _assert_package_set(self, response): + self.assertRecordValues( + self.move_line1 + self.move_line2 + self.move_line3, + [ + { + "result_package_id": self.delivery_package.id, + "shopfloor_checkout_done": True, + }, + { + "result_package_id": self.delivery_package.id, + "shopfloor_checkout_done": True, + }, + # qty_done was zero so we don't set it as packed + {"result_package_id": self.pack1.id, "shopfloor_checkout_done": False}, + ], + ) + self.assert_response( + response, + # go pack to the screen to select lines to put in packages + next_state="select_line", + data=self._data_for_select_line(self.picking), + message=self.msg_store.goods_packed_in(self.delivery_package), + ) + + def test_scan_dest_package_ok(self): + response = self.service.dispatch( + "scan_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + # we keep the goods in the same package, so we scan the source package + "barcode": self.delivery_package.name, + }, + ) + self._assert_package_set(response) + + def test_scan_dest_package_error_not_found(self): + barcode = "NO BARCODE" + response = self.service.dispatch( + "scan_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "barcode": barcode, + }, + ) + self._assert_response_select_dest_package( + response, + self.picking, + self.selected_lines, + self._get_allowed_packages(self.picking), + message=self.service.msg_store.package_not_found_for_barcode(barcode), + ) + + def test_scan_dest_package_error_not_allowed(self): + package = self.env["stock.quant.package"].create({}) + response = self.service.dispatch( + "scan_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "barcode": package.name, + }, + ) + self._assert_response_select_dest_package( + response, + self.picking, + self.selected_lines, + self._get_allowed_packages(self.picking), + message=self.service.msg_store.dest_package_not_valid(package), + ) + + def test_set_dest_package_ok(self): + response = self.service.dispatch( + "set_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "package_id": self.delivery_package.id, + }, + ) + self._assert_package_set(response) + + def test_set_dest_package_ok_on_partial_qty_done(self): + # Partially process line three 3 quantiy out of 10 + self.move_line3.qty_done = 3 + response = self.service.dispatch( + "set_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "package_id": self.delivery_package.id, + }, + ) + # self._assert_package_set(response) + self.assertRecordValues( + self.move_line1 + self.move_line2 + self.move_line3, + [ + { + "result_package_id": self.delivery_package.id, + "shopfloor_checkout_done": True, + }, + { + "result_package_id": self.delivery_package.id, + "shopfloor_checkout_done": True, + }, + # Line 3 has been split + { + "result_package_id": self.delivery_package.id, + "shopfloor_checkout_done": True, + "product_uom_qty": 3, + "qty_done": 3, + }, + ], + ) + # Left quantity to do from line 3 + new_move_line = self.picking.move_line_ids.filtered( + lambda line: line.qty_done == 0 and line.reserved_uom_qty == 7 + ) + self.assertTrue(new_move_line) + self.assertFalse(new_move_line.shopfloor_checkout_done) + self.assert_response( + response, + # go pack to the screen to select lines to put in packages + next_state="select_line", + data=self._data_for_select_line(self.picking), + message=self.msg_store.goods_packed_in(self.delivery_package), + ) + + def test_set_dest_package_error_not_found(self): + response = self.service.dispatch( + "set_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "package_id": 0, + }, + ) + self._assert_response_select_dest_package( + response, + self.picking, + self.selected_lines, + self._get_allowed_packages(self.picking), + message=self.service.msg_store.record_not_found(), + ) + + def test_set_dest_package_error_not_allowed(self): + package = self.env["stock.quant.package"].create({}) + response = self.service.dispatch( + "set_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.selected_lines.ids, + "package_id": package.id, + }, + ) + self._assert_response_select_dest_package( + response, + self.picking, + self.selected_lines, + self._get_allowed_packages(self.picking), + message=self.service.msg_store.dest_package_not_valid(package), + ) + + def test_set_dest_package_error_qty_done_above(self): + # If the qty_done of a selected line goes beyond + # the maximum allowed, a message should be displayed + # and the user shouldn't be allowed to select a package. + line = fields.first(self.picking.move_line_ids) + line.qty_done = line.reserved_uom_qty + 1 + response = self.service.dispatch( + "list_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.picking.move_line_ids.ids, + }, + ) + self._assert_select_package_qty_above(response, self.picking) diff --git a/shopfloor/tests/test_checkout_new_package.py b/shopfloor/tests/test_checkout_new_package.py new file mode 100644 index 0000000000..d1afe51017 --- /dev/null +++ b/shopfloor/tests/test_checkout_new_package.py @@ -0,0 +1,88 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields + +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +class CheckoutNewPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + def test_new_package_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + pack1_moves = picking.move_ids[:3] + pack2_moves = picking.move_ids[3:] + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves, in_package=True) + self._fill_stock_for_moves(pack2_moves, in_package=True) + picking.action_assign() + + selected_lines = pack1_moves.move_line_ids + pack1 = pack1_moves.move_line_ids.package_id + + move_line1, move_line2, move_line3 = selected_lines + # we'll put only the first 2 lines (product A and B) in the new package + move_line1.qty_done = move_line1.reserved_uom_qty + move_line2.qty_done = move_line2.reserved_uom_qty + move_line3.qty_done = 0 + + response = self.service.dispatch( + "new_package", + params={"picking_id": picking.id, "selected_line_ids": selected_lines.ids}, + ) + + new_package = move_line1.result_package_id + self.assertNotEqual(pack1, new_package) + + self.assertRecordValues( + move_line1, + [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}], + ) + self.assertRecordValues( + move_line2, + [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}], + ) + self.assertRecordValues( + move_line3, + # qty_done was zero so we don't set it as packed and it remains in + # the same package + [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}], + ) + self.assert_response( + response, + # go pack to the screen to select lines to put in packages + next_state="select_line", + data=self._data_for_select_line(picking), + message=self.msg_store.goods_packed_in(new_package), + ) + + def test_set_dest_package_error_qty_done_above(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + ] + ) + moves = picking.move_ids + self._fill_stock_for_moves(moves, in_package=True) + picking.action_assign() + # If the qty_done of a selected line goes beyond + # the maximum allowed, a message should be displayed + # and the user shouldn't be allowed to select a package. + selected_lines = moves.move_line_ids + line = fields.first(selected_lines) + line.qty_done = line.reserved_uom_qty + 1 + response = self.service.dispatch( + "list_dest_package", + params={ + "picking_id": picking.id, + "selected_line_ids": picking.move_line_ids.ids, + }, + ) + self._assert_select_package_qty_above(response, picking) diff --git a/shopfloor/tests/test_checkout_no_package.py b/shopfloor/tests/test_checkout_no_package.py new file mode 100644 index 0000000000..a367719e85 --- /dev/null +++ b/shopfloor/tests/test_checkout_no_package.py @@ -0,0 +1,95 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import werkzeug + +from odoo import fields + +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +# pylint: disable=missing-return +class CheckoutNoPackageCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), + ] + ) + cls.pack1_moves = pack1_moves = picking.move_ids[:3] + cls.pack2_moves = pack2_moves = picking.move_ids[3:] + # put in 2 packs, for this test, we'll work on pack1 + cls._fill_stock_for_moves(pack1_moves) + cls._fill_stock_for_moves(pack2_moves) + picking.action_assign() + + def test_no_package_ok(self): + move_line1, move_line2, move_line3 = self.pack1_moves.move_line_ids + selected_lines = move_line1 + move_line2 + + # we'll put only the first 2 lines (product A and B) w/ no package + move_line1.qty_done = move_line1.reserved_uom_qty + move_line2.qty_done = move_line2.reserved_uom_qty + move_line3.qty_done = 0 + response = self.service.dispatch( + "no_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + }, + ) + + self.assertRecordValues( + move_line1, + [{"result_package_id": False, "shopfloor_checkout_done": True}], + ) + self.assertRecordValues( + move_line2, + [{"result_package_id": False, "shopfloor_checkout_done": True}], + ) + self.assertRecordValues( + move_line3, + [{"result_package_id": False, "shopfloor_checkout_done": False}], + ) + self.assert_response( + response, + # go pack to the screen to select lines to put in packages + next_state="select_line", + data=self._data_for_select_line(self.picking), + message={ + "message_type": "success", + "body": "Product(s) processed as raw product(s)", + }, + ) + + def test_no_package_disabled(self): + self.service.work.options = {"checkout__disable_no_package": True} + with self.assertRaises(werkzeug.exceptions.BadRequest) as err: + self.service.dispatch( + "no_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.pack1_moves.move_line_ids.ids, + }, + ) + self.assertEqual(repr(err), "`checkout.no_package` endpoint is not enabled") + + def test_set_dest_package_error_qty_done_above(self): + # If the qty_done of a selected line goes beyond + # the maximum allowed, a message should be displayed + # and the user shouldn't be allowed to select a package. + line = fields.first(self.picking.move_line_ids) + line.qty_done = line.reserved_uom_qty + 1 + response = self.service.dispatch( + "list_dest_package", + params={ + "picking_id": self.picking.id, + "selected_line_ids": self.picking.move_line_ids.ids, + }, + ) + self._assert_select_package_qty_above(response, self.picking) diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py new file mode 100644 index 0000000000..7761ca7196 --- /dev/null +++ b/shopfloor/tests/test_checkout_scan.py @@ -0,0 +1,174 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_checkout_base import CheckoutCommonCase + + +class CheckoutScanCase(CheckoutCommonCase): + def _test_scan_ok(self, barcode_func, in_package=True): + picking = self._create_picking() + self._fill_stock_for_moves(picking.move_ids, in_package=in_package) + picking.action_assign() + barcode = barcode_func(picking) + response = self.service.dispatch("scan_document", params={"barcode": barcode}) + self.assert_response( + response, + next_state="select_line", + data=self._data_for_select_line(picking), + ) + + def test_scan_document_stock_picking_ok(self): + self._test_scan_ok(lambda picking: picking.name) + + def test_scan_document_location_ok(self): + self._test_scan_ok(lambda picking: picking.move_line_ids.location_id.barcode) + + def test_scan_document_package_ok(self): + self._test_scan_ok(lambda picking: picking.move_line_ids.package_id.name) + + def test_scan_document_product_ok(self): + self._test_scan_ok( + lambda picking: picking.move_line_ids.product_id[0].barcode, + in_package=False, + ) + + def test_scan_document_packaging_ok(self): + self._test_scan_ok( + lambda picking: picking.move_line_ids.product_id[0].packaging_ids.barcode, + in_package=False, + ) + + def test_scan_document_error_not_found(self): + response = self.service.dispatch("scan_document", params={"barcode": "NOPE"}) + self.assert_response( + response, + next_state="select_document", + message={"message_type": "error", "body": "Barcode not found"}, + ) + + def _test_scan_document_error_not_available(self, barcode_func): + picking = self._create_picking() + # in this test, we want the picking not to be available, but + # if we leave the shipping policy to direct, a single move assigned + # would make the picking available + picking.move_type = "one" + # the picking will have one line available, so the endpoint can find + # something from a location or package but should reject the picking as + # it is not entirely available + self._fill_stock_for_moves(picking.move_ids[0], in_package=True) + picking.action_assign() + barcode = barcode_func(picking) + response = self.service.dispatch("scan_document", params={"barcode": barcode}) + self.assert_response( + response, + next_state="select_document", + message={ + "message_type": "error", + "body": "Transfer {} is not available.".format(picking.name), + }, + ) + + def test_scan_document_error_not_available_picking(self): + self._test_scan_document_error_not_available(lambda picking: picking.name) + + def test_scan_document_error_not_available_location(self): + self._test_scan_document_error_not_available( + lambda picking: picking.move_line_ids.location_id.barcode + ) + + def test_scan_document_error_not_available_package(self): + self._test_scan_document_error_not_available( + lambda picking: picking.move_line_ids.package_id.name + ) + + def test_scan_document_error_location_not_child_of_type(self): + picking = self._create_picking() + picking.location_id = self.dispatch_location + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + response = self.service.dispatch( + "scan_document", params={"barcode": picking.location_id.barcode} + ) + self.assert_response( + response, + next_state="select_document", + message={"message_type": "error", "body": "Location not allowed here."}, + ) + + def _test_scan_document_error_different_picking_type(self, barcode_func): + picking = self._create_picking(picking_type=self.wh.pick_type_id) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + barcode = barcode_func(picking) + response = self.service.dispatch("scan_document", params={"barcode": barcode}) + self.assert_response( + response, + next_state="select_document", + message={ + "message_type": "error", + "body": "You cannot move this using this menu.", + }, + ) + + def test_scan_document_error_different_picking_type_picking(self): + self._test_scan_document_error_different_picking_type( + lambda picking: picking.name + ) + + def test_scan_document_error_different_picking_type_package(self): + self._test_scan_document_error_different_picking_type( + lambda picking: picking.move_line_ids.package_id.name + ) + + def test_scan_document_error_location_several_pickings(self): + picking = self._create_picking() + # create a second picking at the same place so we don't + # know which picking to use + picking2 = self._create_picking() + pickings = picking | picking2 + self._fill_stock_for_moves(pickings.move_ids, in_package=True) + pickings.action_assign() + response = self.service.dispatch( + "scan_document", + params={"barcode": picking.move_line_ids.location_id.barcode}, + ) + self.assert_response( + response, + next_state="select_document", + message={ + "message_type": "error", + "body": "Several transfers found, please scan a package" + " or select a transfer manually.", + }, + ) + + def test_scan_document_recover(self): + """If the user starts to process a line, and for whatever reason he + stops there and restarts the scenario from the beginning, he should + still be able to find the previous line. + """ + picking = self._create_picking() + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + package = picking.move_line_ids.package_id + # The user selects a line, then stops working in the middle of the process + response = self.service.dispatch( + "scan_document", params={"barcode": package.name} + ) + data = response["data"]["select_line"] + self.assertEqual(data["picking"]["move_line_count"], 2) + self.assertEqual(len(data["picking"]["move_lines"]), 2) + self.assertFalse(picking.move_line_ids.shopfloor_user_id) + response = self.service.dispatch( + "select_line", + params={"picking_id": picking.id, "package_id": package.id}, + ) + self.assertTrue(all(m.qty_done for m in picking.move_line_ids)) + self.assertEqual(picking.move_line_ids.shopfloor_user_id, self.env.user) + # He restarts the scenario and try to select again the previous line + # to continue its job + response = self.service.dispatch( + "scan_document", params={"barcode": package.name} + ) + data = response["data"]["select_line"] + self.assertEqual(data["picking"]["move_line_count"], 2) + self.assertEqual(len(data["picking"]["move_lines"]), 2) # Lines found diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py new file mode 100644 index 0000000000..75aea34c78 --- /dev/null +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -0,0 +1,377 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_checkout_scan_line_base import CheckoutScanLineCaseBase + + +# pylint: disable=missing-return +class CheckoutScanLineCase(CheckoutScanLineCaseBase): + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.delivery_packaging = ( + cls.env["stock.package.type"] + .sudo() + .create( + { + "name": "DelivBox", + "barcode": "DelivBox", + } + ) + ) + + def test_scan_line_package_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + move1 = picking.move_ids[0] + move2 = picking.move_ids[1] + # put the lines in 2 separate packages (only the first line should be selected + # by the package barcode) + self._fill_stock_for_moves(move1, in_package=True) + self._fill_stock_for_moves(move2, in_package=True) + picking.action_assign() + move_line = move1.move_line_ids + self._test_scan_line_ok(move_line.package_id.name, move_line) + + def test_scan_line_package_ok_packing_info_empty_info(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + move1 = picking.move_ids[0] + move2 = picking.move_ids[1] + # put the lines in 2 separate packages (only the first line should be selected + # by the package barcode) + self._fill_stock_for_moves(move1, in_package=True) + self._fill_stock_for_moves(move2, in_package=True) + picking.action_assign() + move_line = move1.move_line_ids + self._test_scan_line_ok(move_line.package_id.name, move_line) + + def test_scan_line_package_several_lines_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + # put all the lines in the same source package + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + package = picking.move_line_ids.mapped("package_id") + self._test_scan_line_ok(package.name, picking.move_line_ids) + + def test_scan_line_product_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + # do not put them in a package, we'll pack units here + self._fill_stock_for_moves(picking.move_ids) + picking.action_assign() + # The product a is scanned, so selected and quantity updated + line_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # Because not part of a package other lines are selected also + related_lines = picking.move_line_ids - line_a + selected_lines = picking.move_line_ids + self._test_scan_line_ok( + self.product_a.barcode, selected_lines, related_lines=related_lines + ) + + def test_scan_line_product_several_lines_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)] + ) + self._fill_stock_for_moves(picking.move_ids) + picking.action_assign() + # The product a is scanned, so selected and quantity updated + lines_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # Because not part of a package other lines are selected also + related_lines = picking.move_line_ids - lines_a + selected_lines = picking.move_line_ids + self._test_scan_line_ok( + self.product_a.barcode, selected_lines, related_lines=related_lines + ) + + def test_scan_line_product_packaging_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)] + ) + self._fill_stock_for_moves(picking.move_ids) + picking.action_assign() + lines_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # when we scan the packaging of the product, we should select the + # lines as if the product was scanned + # Because not part of a package other lines are selected also + related_lines = picking.move_line_ids - lines_a + selected_lines = picking.move_line_ids + self._test_scan_line_ok( + self.product_a_packaging.barcode, selected_lines, related_lines + ) + + def test_scan_line_product_lot_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 1), (self.product_a, 1), (self.product_b, 1)] + ) + for move in picking.move_ids: + self._fill_stock_for_moves(move, in_lot=True) + picking.action_assign() + first_line = picking.move_line_ids[0] + lot = first_line.lot_id + self._test_scan_line_ok(lot.name, first_line) + + def test_scan_line_product_in_one_package_all_package_lines_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + # Product_a and product_b are in the same package, when we scan product_a, + # we expect to work on all the lines of the package. If product_a was in + # more than one package, it would be an error. + self._test_scan_line_ok(self.product_a.barcode, picking.move_line_ids) + + def _test_scan_line_error(self, picking, barcode, message): + """Test errors for /scan_line + + :param picking: the picking we are currently working with (selected) + :param barcode: the barcode we scan + :param message: the dict of expected error message + """ + response = self.service.dispatch( + "scan_line", params={"picking_id": picking.id, "barcode": barcode} + ) + self.assert_response( + response, + next_state="select_line", + data=self._data_for_select_line(picking), + message=message, + ) + + def test_scan_line_error_barcode_not_found(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + self._test_scan_line_error( + picking, + "NOT A BARCODE", + {"message_type": "error", "body": "Barcode not found"}, + ) + + def test_scan_line_error_package_not_in_picking(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking2 = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking2.move_ids, in_package=True) + (picking | picking2).action_assign() + package = picking2.move_line_ids.package_id + # we work with picking, but we scan the package of picking2 + self._test_scan_line_error( + picking, + package.name, + { + "message_type": "error", + "body": "Package {} is not in the current transfer.".format( + package.name + ), + }, + ) + + def test_scan_line_error_product_tracked_by_lot(self): + self.product_a.tracking = "lot" + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + # product tracked by lot, but we scan the product barcode, user + # has to scan the lot + self._test_scan_line_error( + picking, + self.product_a.barcode, + { + "message_type": "warning", + "body": "Product tracked by lot, please scan one.", + }, + ) + + def test_scan_line_error_product_in_two_packages(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10)], + # when action_confirm is called, it would merge the moves + confirm=False, + ) + self._fill_stock_for_moves(picking.move_ids[0], in_package=True) + self._fill_stock_for_moves(picking.move_ids[1], in_package=True) + picking.action_assign() + self._test_scan_line_error( + picking, + self.product_a.barcode, + { + "message_type": "warning", + "body": "This product is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_error_product_in_one_package_and_unit(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10)], + # when action_confirm is called, it would merge the moves + # we want to keep them separated to put a part in a package + confirm=False, + ) + # put the product in one package and the other as unit + self._fill_stock_for_moves(picking.move_ids[0], in_package=True) + self._fill_stock_for_moves(picking.move_ids[1]) + picking.action_assign() + self._test_scan_line_error( + picking, + self.product_a.barcode, + { + "message_type": "warning", + "body": "This product is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_error_product_not_in_picking(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + self._test_scan_line_error( + picking, + self.product_b.barcode, + { + "message_type": "error", + "body": "Product is not in the current transfer.", + }, + ) + + def test_scan_line_error_lot_not_in_picking(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_ids, in_lot=True) + picking.action_assign() + lot = self.env["stock.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + self._test_scan_line_error( + picking, + lot.name, + {"message_type": "error", "body": "Lot is not in the current transfer."}, + ) + + def test_scan_line_error_lot_in_two_packages(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10)], + # when action_confirm is called, it would merge the moves + confirm=False, + ) + # we want the same lot to be used in 2 lines with different packages + lot = self.env["stock.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + self._fill_stock_for_moves(picking.move_ids[0], in_package=True, in_lot=lot) + self._fill_stock_for_moves(picking.move_ids[1], in_package=True, in_lot=lot) + picking.action_assign() + self._test_scan_line_error( + picking, + lot.name, + { + "message_type": "warning", + "body": "This lot is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_error_lot_in_one_package_and_unit(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10)], + # when action_confirm is called, it would merge the moves + confirm=False, + ) + # we want the same lot to be used in 2 lines with different packages + lot = self.env["stock.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + self._fill_stock_for_moves(picking.move_ids[0], in_package=True, in_lot=lot) + self._fill_stock_for_moves(picking.move_ids[1], in_lot=lot) + picking.action_assign() + self._test_scan_line_error( + picking, + lot.name, + { + "message_type": "warning", + "body": "This lot is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_all_lines_done(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + # set all lines as done + picking.move_line_ids.write({"qty_done": 10.0, "shopfloor_checkout_done": True}) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + # the barcode doesn't matter as we have no + # lines to pack anymore + "barcode": self.product_a.barcode, + }, + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(picking, done=True), + "all_processed": True, + }, + ) + + def test_scan_line_delivery_package_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + move1 = picking.move_ids[0] + move2 = picking.move_ids[1] + # put the lines in 2 separate packages (only the first line should be selected + # by the package barcode) + self._fill_stock_for_moves(move1, in_package=True) + self._fill_stock_for_moves(move2, in_package=True) + picking.action_assign() + result_pkgs = picking.move_line_ids.result_package_id + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.delivery_packaging.barcode, + }, + ) + # back to same state + self.assertEqual(response["next_state"], "select_line") + self.assertEqual( + response["message"], + self.msg_store.confirm_put_all_goods_in_delivery_package( + self.delivery_packaging + ), + ) + self.assertTrue(response["data"]["select_line"]["need_confirm_pack_all"]) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.delivery_packaging.barcode, + "confirm_pack_all": True, + }, + ) + # move to summary as all lines are done + self.assertEqual(response["next_state"], "summary") + self.assertTrue(response["message"]["body"].startswith("Goods packed into ")) + self.assertNotEqual( + result_pkgs.sorted("id"), + picking.move_line_ids.result_package_id.sorted("id"), + ) diff --git a/shopfloor/tests/test_checkout_scan_line_base.py b/shopfloor/tests/test_checkout_scan_line_base.py new file mode 100644 index 0000000000..067c27c483 --- /dev/null +++ b/shopfloor/tests/test_checkout_scan_line_base.py @@ -0,0 +1,25 @@ +# Copyright 2021 Camptocamp SA (https://www.camptocamp.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +class CheckoutScanLineCaseBase(CheckoutCommonCase, CheckoutSelectPackageMixin): + def _test_scan_line_ok( + self, barcode, selected_lines, related_lines=None, packing_info="" + ): + """Test /scan_line with a valid return + + :param barcode: the barcode we scan + :selected_lines: expected move lines returned by the endpoint + """ + picking = selected_lines.mapped("picking_id") + response = self.service.dispatch( + "scan_line", params={"picking_id": picking.id, "barcode": barcode} + ) + self._assert_selected( + response, + selected_lines, + related_lines=related_lines, + packing_info=packing_info, + ) diff --git a/shopfloor/tests/test_checkout_scan_line_no_prefill_qty.py b/shopfloor/tests/test_checkout_scan_line_no_prefill_qty.py new file mode 100644 index 0000000000..18d5b8ddaa --- /dev/null +++ b/shopfloor/tests/test_checkout_scan_line_no_prefill_qty.py @@ -0,0 +1,91 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_checkout_scan_line_base import CheckoutScanLineCaseBase + +# pylint: disable=missing-return + + +class CheckoutScanLineNoPrefillQtyCase(CheckoutScanLineCaseBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.menu.sudo().no_prefill_qty = True + cls.picking = cls._create_picking( + lines=[(cls.product_a, 3), (cls.product_a, 1), (cls.product_b, 10)], + confirm=False, + ) + cls.picking.move_ids._action_confirm(merge=False) + cls.picking.action_confirm() + for move in cls.picking.move_ids: + cls._fill_stock_for_moves(move, in_lot=True) + cls.picking.action_assign() + cls.move_lines = cls.picking.move_line_ids + + def _assert_quantity_done(self, barcode, selected_lines, qties): + picking = selected_lines.mapped("picking_id") + response = self.service.dispatch( + "scan_line", params={"picking_id": picking.id, "barcode": barcode} + ) + response_lines = response["data"]["select_package"]["selected_move_lines"] + for response_line, qty in zip(response_lines, qties): + self.assertEqual(response_line["qty_done"], qty) + + def test_scan_line_product_exist_in_two_lines(self): + """Check scanning a product only increment the quantity done on one line.""" + # All lines are selected because not in a package + selected_lines = self.picking.move_line_ids + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": self.picking.id, + "barcode": self.product_a.barcode, + }, + ) + self._assert_selected_qties( + response, + selected_lines, + {selected_lines[0]: 1, selected_lines[1]: 0, selected_lines[2]: 0}, + ) + + def test_scan_line_product_no_prefill_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + # do not put them in a package, we'll pack units here + self._fill_stock_for_moves(picking.move_ids) + picking.action_assign() + line_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # When no_prefill_qty is enabled in the checkout menu, prefilled qty + # should be 1.0 if a product is scanned + qties = [1.0] * len(line_a) + self._assert_quantity_done(self.product_a.barcode, line_a, qties) + + def test_scan_line_product_packaging_no_prefill_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_a, 10), (self.product_b, 10)] + ) + self._fill_stock_for_moves(picking.move_ids) + picking.action_assign() + lines_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # When no_prefill_qty is enabled in the checkout menu, prefilled qty + # should be the packaging qty, if a packaging is scanned + qties = [3.0] * len(lines_a) + self._assert_quantity_done(self.product_a_packaging.barcode, lines_a, qties) + + def test_scan_line_product_lot_no_prefill_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 1), (self.product_a, 1), (self.product_b, 1)] + ) + for move in picking.move_ids: + self._fill_stock_for_moves(move, in_lot=True) + picking.action_assign() + first_line = picking.move_line_ids[0] + lot = first_line.lot_id + # When no_prefill_qty is enabled in the checkout menu, prefilled qty + # should be the packaging qty, if a packaging is scanned + qties = [1.0] * len(first_line) + self._assert_quantity_done(lot.name, first_line, qties) diff --git a/shopfloor/tests/test_checkout_scan_package_action.py b/shopfloor/tests/test_checkout_scan_package_action.py new file mode 100644 index 0000000000..cb3eb4f85b --- /dev/null +++ b/shopfloor/tests/test_checkout_scan_package_action.py @@ -0,0 +1,451 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from itertools import product +from unittest import mock + +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +class CheckoutScanPackageActionCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + def _test_select_product( + self, barcode_func, origin_qty_func, expected_qty_func, in_lot=False + ): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10), (self.product_c, 10)] + ) + for move_line in picking.move_ids: + # put in 3 different packages + self._fill_stock_for_moves(move_line, in_package=True, in_lot=in_lot) + picking.action_assign() + + # we have selected the pack that contains product a + line_a = picking.move_line_ids[0] + line_a.qty_done = origin_qty_func(line_a) + + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": line_a.ids, + "barcode": barcode_func(line_a), + }, + ) + + # since we scanned the barcode of the product and we had a + # qty_done, the qty_done should flip to 0 + self._assert_selected_qties( + response, line_a, {line_a: expected_qty_func(line_a)} + ) + + def test_scan_package_action_select_product(self): + self._test_select_product( + lambda l: l.product_id.barcode, lambda l: l.reserved_uom_qty, lambda __: 0 + ) + + def test_scan_package_action_deselect_product(self): + self._test_select_product( + lambda l: l.product_id.barcode, lambda __: 0, lambda l: l.reserved_uom_qty + ) + + def test_scan_package_action_select_product_packaging(self): + self._test_select_product( + lambda l: l.product_id.packaging_ids.barcode, + lambda l: l.reserved_uom_qty, + lambda __: 0, + ) + + def test_scan_package_action_deselect_product_packaging(self): + self._test_select_product( + lambda l: l.product_id.packaging_ids.barcode, + lambda __: 0, + lambda l: l.reserved_uom_qty, + ) + + def test_scan_package_action_select_product_lot(self): + self._test_select_product( + lambda l: l.lot_id.name, + lambda __: 0, + lambda l: l.reserved_uom_qty, + in_lot=True, + ) + + def test_scan_package_action_deselect_product_lot(self): + self._test_select_product( + lambda l: l.lot_id.name, + lambda l: l.reserved_uom_qty, + lambda __: 0, + in_lot=True, + ) + + def _test_scan_package_action_scan_product_error_tracked_by( + self, tracked_by, barcode + ): + self.product_a.tracking = tracked_by + picking = self._create_picking(lines=[(self.product_a, 1)]) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + move_line = picking.move_line_ids + origin_qty_done = move_line.qty_done + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": move_line.ids, + "barcode": barcode, + }, + ) + self._assert_selected_qties( + response, + move_line, + # no change as the scan was not valid + {move_line: origin_qty_done}, + message={ + "message_type": "warning", + "body": "Product tracked by lot, please scan one.", + }, + ) + + def test_scan_package_action_scan_product_error_tracking(self): + trackings = ("lot", "serial") + barcodes = (self.product_a.barcode, self.product_a.packaging_ids.barcode) + for tracking, barcode in product(trackings, barcodes): + self._test_scan_package_action_scan_product_error_tracked_by( + tracking, barcode + ) + + def test_scan_package_action_scan_package_keep_source_package_error(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + pack1_moves = picking.move_ids[:3] + pack2_moves = picking.move_ids[3:] + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves, in_package=True) + self._fill_stock_for_moves(pack2_moves, in_package=True) + picking.action_assign() + + selected_lines = pack1_moves.move_line_ids + pack1 = pack1_moves.move_line_ids.package_id + + move_line1, move_line2, move_line3 = selected_lines + # We'll put only product A and B in the package + move_line1.qty_done = move_line1.reserved_uom_qty + move_line2.qty_done = move_line2.reserved_uom_qty + move_line3.qty_done = 0 + + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_lines.ids, + # we try to keep the goods in the same package, so we scan the + # source package but this isn't allowed as it is not a delivery + # package (i.e. having a delivery packaging set) + "barcode": pack1.name, + }, + ) + + self.assertRecordValues( + move_line1, + [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}], + ) + self.assertRecordValues( + move_line2, + [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}], + ) + self.assertRecordValues( + move_line3, + # qty_done was zero so it hasn't been done anyway + [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}], + ) + self.assert_response( + response, + # go pack to the screen to select lines to put in packages + next_state="select_package", + data={ + "picking": self.data.picking(picking), + "selected_move_lines": self.data.move_lines(selected_lines), + "packing_info": self.service._data_for_packing_info(picking), + "no_package_enabled": not self.service.options.get( + "checkout__disable_no_package" + ), + }, + message=self.service.msg_store.dest_package_not_valid(pack1), + ) + + def test_scan_package_action_scan_package_error_invalid(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + move = picking.move_ids + self._fill_stock_for_moves(move, in_package=True) + picking.action_assign() + + selected_line = move.move_line_ids + other_package = self.env["stock.quant.package"].create({}) + + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_line.ids, + "barcode": other_package.name, + }, + ) + + self.assertRecordValues( + selected_line, + [ + { + # the result package must remain identical, so equal to the + # source package + "result_package_id": selected_line.package_id.id, + "shopfloor_checkout_done": False, + } + ], + ) + self._assert_selected_response( + response, + selected_line, + message=self.service.msg_store.dest_package_not_valid(other_package), + ) + + def test_scan_package_action_scan_package_use_existing_package_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + pack1_moves = picking.move_ids[:3] + pack2_moves = picking.move_ids[3:] + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves, in_package=True) + self._fill_stock_for_moves(pack2_moves, in_package=True) + picking.action_assign() + + package = self.env["stock.quant.package"].create( + {"package_type_id": self.delivery_packaging.id} + ) + + # assume that product d was already put in a package, + # we must be able to put the lines of pack1 inside the same + pack2_moves.move_line_ids.write( + {"result_package_id": package.id, "shopfloor_checkout_done": True} + ) + + selected_lines = pack1_moves.move_line_ids + # they are all selected + selected_lines.write({"qty_done": 10.0}) + + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_lines.ids, + # use the package that was used for product D + "barcode": package.name, + }, + ) + + self.assertRecordValues( + selected_lines, + [ + {"result_package_id": package.id, "shopfloor_checkout_done": True}, + {"result_package_id": package.id, "shopfloor_checkout_done": True}, + {"result_package_id": package.id, "shopfloor_checkout_done": True}, + ], + ) + + self.assert_response( + response, + # all the lines are packed, so we expect to go the summary screen + next_state="summary", + data={ + "picking": self._stock_picking_data(picking, done=True), + "all_processed": True, + }, + message=self.msg_store.goods_packed_in(package), + ) + + def test_scan_package_action_scan_packaging_ok(self): + picking = self._create_picking( + lines=[ + (self.product_a, 10), + (self.product_b, 10), + (self.product_c, 10), + (self.product_d, 10), + ] + ) + pack1_moves = picking.move_ids[:3] + pack2_moves = picking.move_ids[3:] + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves, in_package=True) + self._fill_stock_for_moves(pack2_moves, in_package=True) + picking.action_assign() + + selected_lines = pack1_moves.move_line_ids + pack1 = pack1_moves.move_line_ids.package_id + + move_line1, move_line2, move_line3 = selected_lines + # we'll put only the first 2 lines (product A and B) in the new package + move_line1.qty_done = move_line1.reserved_uom_qty + move_line2.qty_done = move_line2.reserved_uom_qty + move_line3.qty_done = 0 + + packaging = ( + self.env["stock.package.type"] + .sudo() + .create( + { + "name": "Pallet", + "barcode": "PPP", + "height": 12, + "width": 13, + "packaging_length": 14, + } + ) + ) + + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_lines.ids, + # create a new package using this packaging + "barcode": packaging.barcode, + }, + ) + + new_package = move_line1.result_package_id + self.assertNotEqual(pack1, new_package) + + self.assertRecordValues( + new_package, + [ + { + "package_type_id": packaging.id, + "pack_length": packaging.packaging_length, + "width": packaging.width, + "height": packaging.height, + } + ], + ) + + self.assertRecordValues( + move_line1, + [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}], + ) + self.assertRecordValues( + move_line2, + [{"result_package_id": new_package.id, "shopfloor_checkout_done": True}], + ) + self.assertRecordValues( + move_line3, + # qty_done was zero so we don't set it as packed and it remains in + # the same package + [{"result_package_id": pack1.id, "shopfloor_checkout_done": False}], + ) + self.assert_response( + response, + next_state="select_line", + data=self._data_for_select_line(picking), + message=self.msg_store.goods_packed_in(new_package), + ) + + def test_scan_package_action_scan_packaging_bad_carrier(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.carrier_id = picking.carrier_id.search([], limit=1) + pack1_moves = picking.move_ids + # put in 2 packs, for this test, we'll work on pack1 + self._fill_stock_for_moves(pack1_moves, in_package=True) + picking.action_assign() + selected_lines = pack1_moves.move_line_ids + selected_lines.qty_done = selected_lines.reserved_uom_qty + + packaging = ( + self.env["stock.package.type"] + .sudo() + .create( + { + "name": "DeliverX", + "package_carrier_type": "none", + "barcode": "XXX", + } + ) + ) + # Delivery type and package_carrier_type values + # depend on specific implementations that we don't have as dependency. + # What is important here is to simulate their value when mismatching. + mock1 = mock.patch.object( + type(packaging), + "package_carrier_type", + new_callable=mock.PropertyMock, + ) + mock2 = mock.patch.object( + type(picking.carrier_id), + "delivery_type", + new_callable=mock.PropertyMock, + ) + with mock1 as mocked_package_carrier_type, mock2 as mocked_delivery_type: + # Not matching at all -> bad + mocked_package_carrier_type.return_value = "DHL" + mocked_delivery_type.return_value = "UPS" + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_lines.ids, + # create a new package using this packaging + "barcode": packaging.barcode, + }, + ) + self._assert_selected_response( + response, + selected_lines, + message=self.msg_store.packaging_invalid_for_carrier( + packaging, picking.carrier_id + ), + ) + # No carrier type set on the packaging -> good + mocked_package_carrier_type.return_value = "none" + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_lines.ids, + # create a new package using this packaging + "barcode": packaging.barcode, + }, + ) + self.assertEqual( + response["message"], + self.msg_store.goods_packed_in(selected_lines.result_package_id), + ) + + def test_scan_package_action_scan_not_found(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + move = picking.move_ids + self._fill_stock_for_moves(move, in_package=True) + picking.action_assign() + selected_line = move.move_line_ids + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": selected_line.ids, + # create a new package using this packaging + "barcode": "BARCODE NOT FOUND", + }, + ) + self._assert_selected_response( + response, + selected_line, + message={"message_type": "error", "body": "Barcode not found"}, + ) diff --git a/shopfloor/tests/test_checkout_scan_package_action_no_prefill_qty.py b/shopfloor/tests/test_checkout_scan_package_action_no_prefill_qty.py new file mode 100644 index 0000000000..13b6764491 --- /dev/null +++ b/shopfloor/tests/test_checkout_scan_package_action_no_prefill_qty.py @@ -0,0 +1,107 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + +# pylint: disable=missing-return + + +class CheckoutScanPackageActionCaseNoPrefillQty( + CheckoutCommonCase, CheckoutSelectPackageMixin +): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.menu.sudo().no_prefill_qty = True + + def test_scan_package_action_scan_product_to_increment_qty(self): + """ """ + picking = self._create_picking(lines=[(self.product_a, 3)]) + self._fill_stock_for_moves(picking.move_ids, in_package=False) + picking.action_assign() + move_line = picking.move_line_ids + origin_qty_done = move_line.qty_done = 2 + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": move_line.ids, + "barcode": move_line.product_id.barcode, + }, + ) + self._assert_selected_qties( + response, + move_line, + {move_line: origin_qty_done + 1}, + ) + + def test_scan_package_action_scan_product2_to_increment_qty(self): + """Scan a product which is present in two lines. + + Only one line should have its quantity incremented. + + """ + picking = self._create_picking( + lines=[(self.product_a, 3), (self.product_a, 1)], confirm=False + ) + picking.move_ids._action_confirm(merge=False) + picking.action_confirm() + self._fill_stock_for_moves(picking.move_ids, in_package=False) + picking.action_assign() + move_lines = picking.move_line_ids + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": move_lines.ids, + "barcode": self.product_a.barcode, + }, + ) + self._assert_selected_qties( + response, + move_lines, + {move_lines[0]: 1, move_lines[1]: 0}, + ) + + def test_scan_package_action_scan_lot_to_increment_qty(self): + """ """ + picking = self._create_picking(lines=[(self.product_a, 3)]) + self._fill_stock_for_moves(picking.move_ids, in_lot=True) + picking.action_assign() + move_line = picking.move_line_ids + origin_qty_done = move_line.qty_done = 2 + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": move_line.ids, + "barcode": move_line.lot_id.name, + }, + ) + self._assert_selected_qties( + response, + move_line, + {move_line: origin_qty_done + 1}, + ) + + def test_scan_package_action_scan_packaging_to_increment_qty(self): + """ """ + picking = self._create_picking(lines=[(self.product_a, 3)]) + self._fill_stock_for_moves(picking.move_ids, in_package=True, in_lot=False) + picking.action_assign() + move_line = picking.move_line_ids + origin_qty_done = move_line.qty_done = 0 + response = self.service.dispatch( + "scan_package_action", + params={ + "picking_id": picking.id, + "selected_line_ids": move_line.ids, + "barcode": self.product_a_packaging.barcode, + }, + ) + self._assert_selected_qties( + response, + move_line, + {move_line: origin_qty_done + self.product_a_packaging.qty}, + ) diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py new file mode 100644 index 0000000000..173b0aaf10 --- /dev/null +++ b/shopfloor/tests/test_checkout_select.py @@ -0,0 +1,74 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_checkout_base import CheckoutCommonCase + +# pylint: disable=missing-return + + +class CheckoutListStockPickingCase(CheckoutCommonCase): + def test_list_stock_picking(self): + picking1 = self._create_picking() + picking2 = self._create_picking() + # should not be in the list because another type: + picking3 = self._create_picking(picking_type=self.wh.pick_type_id) + # should not be in list because not assigned: + self._create_picking() + to_assign = picking1 | picking2 | picking3 + self._fill_stock_for_moves(to_assign.move_ids, in_package=True) + to_assign.action_assign() + response = self.service.dispatch("list_stock_picking", params={}) + expected = { + "pickings": [ + self._picking_summary_data(picking1), + self._picking_summary_data(picking2), + ] + } + + self.assert_response(response, next_state="manual_selection", data=expected) + + +class CheckoutSelectCase(CheckoutCommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = cls._create_picking() + cls._fill_stock_for_moves(cls.picking.move_ids, in_package=True) + cls.picking.action_assign() + + def test_select_ok(self): + response = self.service.dispatch( + "select", params={"picking_id": self.picking.id} + ) + self.assert_response( + response, + next_state="select_line", + data=self._data_for_select_line(self.picking), + ) + + def _test_error(self, picking, msg): + response = self.service.dispatch("select", params={"picking_id": picking.id}) + self.assert_response( + response, + next_state="manual_selection", + message={"message_type": "error", "body": msg}, + data={"pickings": [self._picking_summary_data(self.picking)]}, + ) + + def test_select_error_not_found(self): + picking = self._create_picking() + picking.sudo().unlink() + self._test_error( + picking, self.service.msg_store.stock_picking_not_found()["body"] + ) + + def test_select_error_not_available(self): + picking = self._create_picking() + self._test_error( + picking, self.service.msg_store.stock_picking_not_available(picking)["body"] + ) + + def test_select_error_not_allowed(self): + picking = self._create_picking(picking_type=self.wh.pick_type_id) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + self._test_error(picking, "You cannot move this using this menu.") diff --git a/shopfloor/tests/test_checkout_select_line.py b/shopfloor/tests/test_checkout_select_line.py new file mode 100644 index 0000000000..33af8cd821 --- /dev/null +++ b/shopfloor/tests/test_checkout_select_line.py @@ -0,0 +1,130 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + + +# pylint: disable=missing-return +class CheckoutSelectLineCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10)] + ) + cls.moves_pack = picking.move_ids[:2] + cls.move_single = picking.move_ids[2:] + cls._fill_stock_for_moves(cls.moves_pack, in_package=True) + cls._fill_stock_for_moves(cls.move_single) + picking.action_assign() + cls.picking = picking + + def test_select_line_package_ok(self): + selected_lines = self.moves_pack.move_line_ids + # we want to check that when we give the package id, we get + # all its move lines + response = self.service.dispatch( + "select_line", + params={ + "picking_id": self.picking.id, + "package_id": selected_lines.package_id.id, + }, + ) + self._assert_selected(response, selected_lines) + + def test_select_line_no_package_disabled(self): + selected_lines = self.moves_pack.move_line_ids + self.service.work.options = {"checkout__disable_no_package": True} + response = self.service.dispatch( + "select_line", + params={ + "picking_id": self.picking.id, + "package_id": selected_lines.package_id.id, + }, + ) + self._assert_selected(response, selected_lines, no_package_enabled=False) + + def test_select_line_move_line_package_ok(self): + selected_lines = self.moves_pack.move_line_ids + # When we select a single line but the line is part of a package, + # we select all the lines. Note: not really supposed to happen as + # the client application should use send a package id when there is + # a package and use the move line id only for lines without package + response = self.service.dispatch( + "select_line", + params={ + "picking_id": self.picking.id, + "move_line_id": selected_lines[0].id, + }, + ) + self._assert_selected(response, selected_lines) + + def test_select_line_move_line_ok(self): + selected_lines = self.move_single.move_line_ids + response = self.service.dispatch( + "select_line", + params={ + "picking_id": self.picking.id, + "move_line_id": selected_lines[0].id, + }, + ) + self._assert_selected(response, selected_lines) + + def _test_select_line_error(self, params, message): + """Test errors for /select_line + + :param params: params sent to /select_line + :param message: the dict of expected error message + """ + response = self.service.dispatch("select_line", params=params) + self.assert_response( + response, + next_state="select_line", + data=self._data_for_select_line(self.picking), + message=message, + ) + + def test_select_line_package_error_not_found(self): + selected_lines = self.move_single.move_line_ids + selected_lines.unlink() + self._test_select_line_error( + {"picking_id": self.picking.id, "package_id": selected_lines[0].id}, + { + "message_type": "error", + "body": "The record you were working on does not exist anymore.", + }, + ) + + def test_select_line_move_line_error_not_found(self): + selected_lines = self.move_single.move_line_ids + selected_lines.unlink() + self._test_select_line_error( + {"picking_id": self.picking.id, "move_line_id": selected_lines[0].id}, + { + "message_type": "error", + "body": "The record you were working on does not exist anymore.", + }, + ) + + def test_select_line_all_lines_done(self): + # set all lines as done + self.picking.move_line_ids.write( + {"qty_done": 10.0, "shopfloor_checkout_done": True} + ) + response = self.service.dispatch( + "select_line", + params={ + "picking_id": self.picking.id, + # doesn't matter as all lines are done, we should be + # redirected to the summary + "package_id": self.picking.move_line_ids[0].package_id.id, + }, + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": True, + }, + ) diff --git a/shopfloor/tests/test_checkout_select_package_base.py b/shopfloor/tests/test_checkout_select_package_base.py new file mode 100644 index 0000000000..e837a9ca7c --- /dev/null +++ b/shopfloor/tests/test_checkout_select_package_base.py @@ -0,0 +1,64 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +class CheckoutSelectPackageMixin: + def _assert_selected_response( + self, + response, + selected_lines, + message=None, + packing_info="", + no_package_enabled=True, + ): + picking = selected_lines.mapped("picking_id") + self.assert_response( + response, + next_state="select_package", + data={ + "selected_move_lines": [ + self._move_line_data(ml) for ml in selected_lines.sorted() + ], + "picking": self._picking_summary_data(picking), + "packing_info": packing_info, + "no_package_enabled": no_package_enabled, + }, + message=message, + ) + + def _assert_selected_qties( + self, + response, + selected_lines, + lines_quantities, + message=None, + packing_info="", + ): + picking = selected_lines.mapped("picking_id") + deselected_lines = picking.move_line_ids - selected_lines + self.assertEqual( + sorted(selected_lines.ids), sorted([line.id for line in lines_quantities]) + ) + for line, quantity in lines_quantities.items(): + self.assertEqual(line.qty_done, quantity) + for line in deselected_lines: + self.assertEqual(line.qty_done, 0, "Lines deselected must have no qty done") + self._assert_selected_response( + response, selected_lines, message=message, packing_info=packing_info + ) + + def _assert_selected( + self, response, selected_lines, related_lines=None, message=None, **kw + ): + related_lines = related_lines or self.env["stock.move.line"] + picking = selected_lines.mapped("picking_id") + unselected_lines = picking.move_line_ids - selected_lines + for line in selected_lines - related_lines: + self.assertEqual( + line.qty_done, + line.reserved_uom_qty, + "Scanned lines must have their qty done set to the reserved quantity", + ) + for line in unselected_lines + related_lines: + self.assertEqual(line.qty_done, 0) + self._assert_selected_response(response, selected_lines, message=message, **kw) diff --git a/shopfloor/tests/test_checkout_set_qty.py b/shopfloor/tests/test_checkout_set_qty.py new file mode 100644 index 0000000000..4739bacc90 --- /dev/null +++ b/shopfloor/tests/test_checkout_set_qty.py @@ -0,0 +1,257 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_checkout_base import CheckoutCommonCase +from .test_checkout_select_package_base import CheckoutSelectPackageMixin + +# pylint: disable=missing-return + + +class CheckoutSetQtyCommonCase(CheckoutCommonCase, CheckoutSelectPackageMixin): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10), (cls.product_c, 10)] + ) + cls.moves_pack1 = picking.move_ids[:2] + cls.moves_pack2 = picking.move_ids[2:] + cls._fill_stock_for_moves(cls.moves_pack1, in_package=True) + cls._fill_stock_for_moves(cls.moves_pack2, in_package=True) + picking.action_assign() + cls.picking = picking + + def setUp(self): + super().setUp() + # we assume we have called /select_line on pack one, so by default, we + # expect the lines for product a and b to have their qty_done set to + # their reserved_uom_qty at the start of the tests + self.selected_lines = self.moves_pack1.move_line_ids + self.deselected_lines = self.moves_pack2.move_line_ids + self.service._select_lines(self.selected_lines) + self.assertTrue( + all(line.qty_done == line.reserved_uom_qty for line in self.selected_lines) + ) + self.assertTrue(all(line.qty_done == 0 for line in self.deselected_lines)) + + +class CheckoutResetLineQtyCase(CheckoutSetQtyCommonCase): + def test_reset_line_qty_ok(self): + selected_lines = self.moves_pack1.move_line_ids + line_to_reset = selected_lines[0] + line_with_qty = selected_lines[1] + # we want to check that when we give the package id, we get + # all its move lines + response = self.service.dispatch( + "reset_line_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line_to_reset.id, + }, + ) + self._assert_selected_qties( + response, + selected_lines, + {line_to_reset: 0, line_with_qty: line_with_qty.reserved_uom_qty}, + ) + + def test_reset_line_qty_not_found(self): + selected_lines = self.moves_pack1.move_line_ids + response = self.service.dispatch( + "reset_line_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": 0, + }, + ) + # if the move line is not found, ignore and return to the + # screen + self._assert_selected_qties( + response, + selected_lines, + {line: line.reserved_uom_qty for line in selected_lines}, + message={ + "body": "The record you were working on does not exist anymore.", + "message_type": "error", + }, + ) + + +class CheckoutSetLineQtyCase(CheckoutSetQtyCommonCase): + def test_set_line_qty_ok(self): + selected_lines = self.moves_pack1.move_line_ids + # do as if the user removed the qties of the 2 selected lines + selected_lines.qty_done = 0 + line_to_set = selected_lines[0] + line_no_qty = selected_lines[1] + # we want to check that when we give the package id, we get + # all its move lines + response = self.service.dispatch( + "set_line_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line_to_set.id, + }, + ) + self.assertEqual(line_to_set.qty_done, line_to_set.reserved_uom_qty) + self.assertEqual(line_no_qty.qty_done, 0) + self._assert_selected_qties( + response, + selected_lines, + {line_to_set: line_to_set.reserved_uom_qty, line_no_qty: 0}, + ) + + def test_set_line_qty_not_found(self): + selected_lines = self.moves_pack1.move_line_ids + response = self.service.dispatch( + "set_line_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": 0, + }, + ) + # if the move line is not found, ignore and return to the + # screen + self._assert_selected_qties( + response, + selected_lines, + {line: line.reserved_uom_qty for line in selected_lines}, + message={ + "body": "The record you were working on does not exist anymore.", + "message_type": "error", + }, + ) + + +class CheckoutSetCustomQtyCase(CheckoutSetQtyCommonCase): + def test_set_custom_qty_ok(self): + selected_lines = self.moves_pack1.move_line_ids + line_to_change = selected_lines[0] + line_keep_qty = selected_lines[1] + # Process full qty + new_qty = line_to_change.reserved_uom_qty + # we want to check that when we give the package id, we get + # all its move lines + response = self.service.dispatch( + "set_custom_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line_to_change.id, + "qty_done": new_qty, + }, + ) + self.assertEqual(line_to_change.qty_done, new_qty) + self.assertEqual(line_keep_qty.qty_done, line_keep_qty.reserved_uom_qty) + self._assert_selected_qties( + response, + selected_lines, + {line_to_change: new_qty, line_keep_qty: line_keep_qty.reserved_uom_qty}, + ) + + def test_set_custom_qty_not_found(self): + selected_lines = self.moves_pack1.move_line_ids + response = self.service.dispatch( + "set_custom_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": 0, + "qty_done": 3, + }, + ) + # if the move line is not found, ignore and return to the + # screen + self._assert_selected_qties( + response, + selected_lines, + {line: line.reserved_uom_qty for line in selected_lines}, + message={ + "body": "The record you were working on does not exist anymore.", + "message_type": "error", + }, + ) + + def test_set_custom_qty_above(self): + selected_lines = self.moves_pack1.move_line_ids + line1 = selected_lines[0] + # modify so we can check that a too high quantity set the max + line1.qty_done = 1 + line2 = selected_lines[1] + response = self.service.dispatch( + "set_custom_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line1.id, + "qty_done": line1.reserved_uom_qty + 1, + }, + ) + self._assert_selected_qties( + response, + selected_lines, + {line1: line1.reserved_uom_qty + 1, line2: line2.reserved_uom_qty}, + message={ + "body": "Please note that the scanned quantity " + "is higher than the maximum allowed.", + "message_type": "warning", + }, + ) + + def test_set_custom_qty_negative(self): + selected_lines = self.moves_pack1.move_line_ids + line1 = selected_lines[0] + line2 = selected_lines[1] + response = self.service.dispatch( + "set_custom_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line1.id, + "qty_done": -1, + }, + ) + self._assert_selected_qties( + response, + selected_lines, + {line1: line1.reserved_uom_qty, line2: line2.reserved_uom_qty}, + message={ + "body": "Negative quantity not allowed.", + "message_type": "error", + }, + ) + + def test_set_custom_qty_partial(self): + selected_lines = self.moves_pack1.move_line_ids + line_to_change = selected_lines[0] + line_keep_qty = selected_lines[1] + # split 1 qty + new_qty = line_to_change.reserved_uom_qty - 1 + response = self.service.dispatch( + "set_custom_qty", + params={ + "picking_id": self.picking.id, + "selected_line_ids": selected_lines.ids, + "move_line_id": line_to_change.id, + "qty_done": new_qty, + }, + ) + self.assertEqual(line_to_change.qty_done, new_qty) + self.assertEqual(line_keep_qty.qty_done, line_keep_qty.reserved_uom_qty) + new_lines = [ + x for x in self.moves_pack1.move_line_ids if x not in selected_lines + ] + # Lines are not being split anymore + self.assertFalse(new_lines) + self._assert_selected_qties( + response, + self.moves_pack1.move_line_ids, + { + line_to_change: new_qty, + line_keep_qty: line_keep_qty.reserved_uom_qty, + }, + ) diff --git a/shopfloor/tests/test_checkout_summary.py b/shopfloor/tests/test_checkout_summary.py new file mode 100644 index 0000000000..b46dd0cb4c --- /dev/null +++ b/shopfloor/tests/test_checkout_summary.py @@ -0,0 +1,69 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_checkout_base import CheckoutCommonCase + +# pylint: disable=missing-return + + +class CheckoutSummaryCase(CheckoutCommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = cls._create_picking( + lines=[ + (cls.product_a, 10), + (cls.product_b, 10), + (cls.product_c, 10), + (cls.product_d, 10), + ] + ) + + def test_summary_picking_not_ready(self): + response = self.service.dispatch( + "summary", params={"picking_id": self.picking.id} + ) + self.assert_response( + response, + next_state="select_document", + data={}, + message=self.service.msg_store.stock_picking_not_available(self.picking), + ) + + def test_summary_not_fully_processed(self): + self._fill_stock_for_moves(self.picking.move_ids, in_package=True) + self.picking.action_assign() + # satisfy only few lines + for ml in self.picking.move_line_ids[:2]: + ml.qty_done = ml.reserved_uom_qty + ml.shopfloor_checkout_done = True + response = self.service.dispatch( + "summary", params={"picking_id": self.picking.id} + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": False, + }, + ) + + def test_summary_fully_processed(self): + self._fill_stock_for_moves(self.picking.move_ids, in_package=True) + self.picking.action_assign() + # satisfy only all lines + for ml in self.picking.move_line_ids: + ml.qty_done = ml.reserved_uom_qty + ml.shopfloor_checkout_done = True + response = self.service.dispatch( + "summary", params={"picking_id": self.picking.id} + ) + self.assert_response( + response, + next_state="summary", + data={ + "picking": self._stock_picking_data(self.picking, done=True), + "all_processed": True, + }, + ) diff --git a/shopfloor/tests/test_cluster_picking_base.py b/shopfloor/tests/test_cluster_picking_base.py new file mode 100644 index 0000000000..6aff5bdb9d --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_base.py @@ -0,0 +1,83 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .common import CommonCase, PickingBatchMixin + +# pylint: disable=missing-return + + +class ClusterPickingCommonCase(CommonCase, PickingBatchMixin): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_demo_cluster_picking") + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.wh.sudo().delivery_steps = "pick_pack_ship" + + def setUp(self): + super().setUp() + self.service = self.get_service( + "cluster_picking", menu=self.menu, profile=self.profile + ) + + def _line_data(self, move_line, qty=None, package_dest=False, qty_done=None, **kw): + picking = move_line.picking_id + # A package exists on the move line, because the quant created + # by ``_simulate_batch_selected`` has a package. + data = self.data.move_line(move_line) + if not package_dest: + data["package_dest"] = None + if qty: + data["quantity"] = qty + if qty_done: + data["qty_done"] = qty_done + data.update( + { + "batch": self.data.picking_batch(picking.batch_id), + "picking": self.data.picking(picking), + "scan_location_or_pack_first": False, + } + ) + data.update(kw) + return data + + @classmethod + def _set_dest_package_and_done(cls, move_lines, dest_package): + """Simulate what would have been done in the previous steps""" + for line in move_lines: + line.write( + { + "qty_done": line.reserved_uom_qty, + "result_package_id": dest_package.id, + } + ) + + def _data_for_batch(self, batch, location, pack=None): + data = self.data.picking_batch(batch) + data["location_dest"] = self.data.location(location) + if pack: + data["package"] = self.data.package(pack) + return data + + +class ClusterPickingLineCommonCase(ClusterPickingCommonCase): + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + # quants already existing are from demo data + cls.env["stock.quant"].sudo().search( + [("location_id", "=", cls.stock_location.id)] + ).unlink() + cls.batch = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + + def _line_data(self, move_line, qty=1.0, **kw): + # just force qty to 1.0 + return super()._line_data(move_line, qty=qty, **kw) diff --git a/shopfloor/tests/test_cluster_picking_batch.py b/shopfloor/tests/test_cluster_picking_batch.py new file mode 100644 index 0000000000..25b6f04a79 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_batch.py @@ -0,0 +1,109 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .common import CommonCase, PickingBatchMixin + +# pylint: disable=missing-return + + +class ClusterPickingBatchCase(CommonCase, PickingBatchMixin): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_demo_cluster_picking") + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.product_a = ( + cls.env["product.product"] + .sudo() + .create({"name": "Product A", "type": "product"}) + ) + cls.product_b = ( + cls.env["product.product"] + .sudo() + .create({"name": "Product B", "type": "product"}) + ) + cls.batch1 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch2 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch3 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls.batch4 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_b, quantity=1)]] + ) + cls.batch5 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_b, quantity=1)]] + ) + cls.batch6 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_b, quantity=1)]] + ) + cls.all_batches = ( + cls.batch1 + cls.batch2 + cls.batch3 + cls.batch4 + cls.batch5 + cls.batch6 + ) + + def setUp(self): + super().setUp() + self.service = self.get_service( + "cluster_picking", menu=self.menu, profile=self.profile + ) + + def test_search_empty(self): + """No batch is available""" + # Simulate the client asking the list of picking batch + # none of the pickings are assigned, so we can't work on them + self.assertFalse(self.service._batch_picking_search()) + + def test_search(self): + """Return only draft batches with assigned pickings""" + pickings = self.all_batches.mapped("picking_ids") + self._fill_stock_for_moves(pickings.mapped("move_ids")) + pickings.action_assign() + self.assertTrue(all(p.state == "assigned" for p in pickings)) + # we should not have done batches in list + self.batch5.state = "done" + # nor canceled batches + self.batch6.state = "cancel" + # we should not have batches in progress + self.batch4.user_id = self.env.ref("base.user_demo") + self.batch4.action_confirm() + # unless it's assigned to our user + self.batch3.user_id = self.env.user + self.batch3.action_confirm() + + # Simulate the client asking the list of picking batch + res = self.service._batch_picking_search() + self.assertRecordValues( + res, + [ + { + "id": self.batch1.id, + "name": self.batch1.name, + "picking_count": 1, + "move_line_count": 1, + "total_weight": 0.0, + }, + { + "id": self.batch2.id, + "name": self.batch2.name, + "picking_count": 1, + "move_line_count": 1, + "total_weight": 0.0, + }, + { + "id": self.batch3.id, + "name": self.batch3.name, + "picking_count": 1, + "move_line_count": 1, + "total_weight": 0.0, + }, + ], + ) diff --git a/shopfloor/tests/test_cluster_picking_change_pack_lot.py b/shopfloor/tests/test_cluster_picking_change_pack_lot.py new file mode 100644 index 0000000000..5584d55eac --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_change_pack_lot.py @@ -0,0 +1,111 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingCommonCase + +# pylint: disable=missing-return + + +class ClusterPickingChangePackLotCase(ClusterPickingCommonCase): + """Tests covering the /change_pack_lot endpoint + + Only simple cases are tested to check the flow of responses on success and + error, the "change.package.lot" component is tested in its own tests. + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=10)]] + ) + + def _test_change_pack_lot(self, line, barcode, success=True, message=None): + batch = line.picking_id.batch_id + response = self.service.dispatch( + "change_pack_lot", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": barcode, + "quantity": line.qty_done, + }, + ) + if success: + self.assert_response( + response, + message=message, + next_state="scan_destination", + data=self._line_data(line), + ) + else: + self.assert_response( + response, + message=message, + next_state="change_pack_lot", + data=self._line_data(line), + ) + return response + + def test_change_pack_lot_change_pack_ok(self): + initial_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + self._simulate_batch_selected(self.batch, fill_stock=False) + + # ensure we have our new package in the same location + new_package = self._create_package_in_location( + self.shelf1, [self.PackageContent(self.product_a, 10, lot=None)] + ) + + line = self.batch.picking_ids.move_line_ids + self._test_change_pack_lot( + line, + new_package.name, + success=True, + message=self.service.msg_store.package_replaced_by_package( + initial_package, new_package + ), + ) + + self.assertRecordValues( + line, + [ + { + "package_id": new_package.id, + "result_package_id": new_package.id, + "reserved_qty": 10.0, + } + ], + ) + self.assertRecordValues(line.package_level_id, [{"package_id": new_package.id}]) + + def test_change_pack_lot_change_lot_ok(self): + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + self._simulate_batch_selected(self.batch, fill_stock=False) + line = self.batch.picking_ids.move_line_ids + source_location = line.location_id + new_lot = self._create_lot(self.product_a) + # ensure we have our new package in the same location + self._update_qty_in_location(source_location, line.product_id, 10, lot=new_lot) + self._test_change_pack_lot( + line, + new_lot.name, + success=True, + message=self.service.msg_store.lot_replaced_by_lot(initial_lot, new_lot), + ) + self.assertRecordValues(line, [{"lot_id": new_lot.id}]) + + def test_change_pack_lot_change_error(self): + initial_lot = self._create_lot(self.product_a) + self._update_qty_in_location(self.shelf1, self.product_a, 10, lot=initial_lot) + self._simulate_batch_selected(self.batch, fill_stock=False) + line = self.batch.picking_ids.move_line_ids + # ensure we have our new package in the same location + self._test_change_pack_lot( + line, + "NOT_FOUND", + success=False, + message=self.service.msg_store.no_package_or_lot_for_barcode("NOT_FOUND"), + ) diff --git a/shopfloor/tests/test_cluster_picking_is_zero.py b/shopfloor/tests/test_cluster_picking_is_zero.py new file mode 100644 index 0000000000..338dd4e663 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_is_zero.py @@ -0,0 +1,98 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingCommonCase + +# pylint: disable=missing-return + + +class ClusterPickingIsZeroCase(ClusterPickingCommonCase): + """Tests covering the /is_zero endpoint + + After a line has been scanned, if the location is empty, the + client application is redirected to the "zero_check" state, + where the user has to confirm or not that the location is empty. + When the location is empty, there is nothing to do, but when it + in fact not empty, a draft inventory must be created for the + product so someone can verify. + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ] + ] + ) + cls.picking = cls.batch.picking_ids + cls._simulate_batch_selected(cls.batch) + + cls.line = cls.picking.move_line_ids[0] + cls.next_line = cls.picking.move_line_ids[1] + cls.bin1 = cls.env["stock.quant.package"].create({}) + cls._update_qty_in_location( + cls.line.location_id, cls.line.product_id, cls.line.reserved_uom_qty + ) + # we already scan and put the first line in bin1, at this point the + # system see the location is empty and reach "zero_check" + cls._set_dest_package_and_done(cls.line, cls.bin1) + + def test_is_zero_is_empty(self): + """call /is_zero confirming it's empty""" + response = self.service.dispatch( + "is_zero", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": self.line.id, + "zero": True, + }, + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(self.next_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + self.line.qty_done, + self.line.product_id.display_name, + self.bin1.name, + ), + }, + ) + + def test_is_zero_is_not_empty(self): + """call /is_zero not confirming it's empty""" + response = self.service.dispatch( + "is_zero", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": self.line.id, + "zero": False, + }, + ) + quant = self.env["stock.quant"].search( + [ + ("location_id", "=", self.line.location_id.id), + ("product_id", "=", self.line.product_id.id), + ("inventory_quantity_set", "=", True), + ] + ) + self.assertTrue(quant) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(self.next_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + self.line.qty_done, + self.line.product_id.display_name, + self.bin1.name, + ), + }, + ) diff --git a/shopfloor/tests/test_cluster_picking_scan_destination.py b/shopfloor/tests/test_cluster_picking_scan_destination.py new file mode 100644 index 0000000000..18f9af22ac --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_scan_destination.py @@ -0,0 +1,376 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingCommonCase + +# pylint: disable=missing-return + + +class ClusterPickingScanDestinationPackCase(ClusterPickingCommonCase): + """Tests covering the /scan_destination_pack endpoint + + After a batch has been selected and the user confirmed they are + working on it, user picked the good, now they scan the location + destination. + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ], + [cls.BatchProduct(product=cls.product_a, quantity=10)], + ] + ) + cls.one_line_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_ids) == 1 + ) + cls.two_lines_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_ids) == 2 + ) + + cls.bin1 = cls.env["stock.quant.package"].create({}) + cls.bin2 = cls.env["stock.quant.package"].create({}) + + cls._simulate_batch_selected(cls.batch) + + def test_scan_destination_pack_ok(self): + """Happy path for scan destination package + + It sets the line in the pack for the full qty + """ + line = self.batch.picking_ids.move_line_ids[0] + next_line = self.batch.picking_ids.move_line_ids[1] + qty_done = line.reserved_uom_qty + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": qty_done, + }, + ) + self.assertRecordValues( + line, [{"qty_done": qty_done, "result_package_id": self.bin1.id}] + ) + self.assert_response( + response, + next_state="start_line", + data=self._line_data(next_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + line.qty_done, line.product_id.display_name, self.bin1.name + ), + }, + ) + + def test_scan_destination_pack_ok_last_line(self): + """Happy path for scan destination package + + It sets the line in the pack for the full qty + """ + self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1) + self._set_dest_package_and_done( + self.two_lines_picking.move_line_ids[0], self.bin2 + ) + # this is the only remaining line to pick + line = self.two_lines_picking.move_line_ids[1] + qty_done = line.reserved_uom_qty + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin2.name, + "quantity": qty_done, + }, + ) + self.assertRecordValues( + line, [{"qty_done": qty_done, "result_package_id": self.bin2.id}] + ) + data = self._data_for_batch(self.batch, self.packing_location) + self.assert_response( + response, + # they reach the same destination so next state unload_all + next_state="unload_all", + data=data, + ) + + def test_scan_destination_pack_not_empty_same_picking(self): + """Scan a destination package with move lines of same picking""" + line1 = self.two_lines_picking.move_line_ids[0] + line2 = self.two_lines_picking.move_line_ids[1] + # we already scan and put the first line in bin1 + self._set_dest_package_and_done(line1, self.bin1) + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line2.id, + # this bin is used for the same picking, should be allowed + "barcode": self.bin1.name, + "quantity": line2.reserved_uom_qty, + }, + ) + self.assert_response( + response, + next_state="start_line", + # we did not pick this line, so it should go there + data=self._line_data(self.one_line_picking.move_line_ids), + message=self.ANY, + ) + + def test_scan_destination_pack_not_empty_different_picking(self): + """Scan a destination package with move lines of other picking""" + # do as if the user already picked the first good (for another picking) + # and put it in bin1 + self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1) + line = self.two_lines_picking.move_line_ids[0] + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + # this bin is used for the other picking + "barcode": self.bin1.name, + "quantity": line.reserved_uom_qty, + }, + ) + self.assertRecordValues(line, [{"qty_done": 0, "result_package_id": False}]) + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line, qty_done=10.0), + message={ + "message_type": "error", + "body": "The destination bin {} is not empty," + " please take another.".format(self.bin1.name), + }, + ) + + def test_scan_destination_pack_not_empty_multi_pick_allowed(self): + """Scan a destination package with move lines of other picking""" + # do as if the user already picked the first good (for another picking) + # and put it in bin1 + self.menu.sudo().write( + {"unload_package_at_destination": True, "multiple_move_single_pack": True} + ) + self._set_dest_package_and_done(self.one_line_picking.move_line_ids, self.bin1) + line = self.two_lines_picking.move_line_ids[0] + self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + # this bin is used for the other picking + "barcode": self.bin1.name, + "quantity": line.reserved_uom_qty, + }, + ) + # Since `multiple_move_single_pack` is enabled, assigning `bin` should be ok + new_line = self.two_lines_picking.move_line_ids - line + self.assertRecordValues( + line, + [ + { + "qty_done": 10, + "result_package_id": self.bin1.id, + "reserved_uom_qty": 10, + } + ], + ) + self.assertRecordValues( + new_line, + [{"qty_done": 0, "result_package_id": False, "reserved_uom_qty": 10}], + ) + + def test_scan_destination_pack_bin_not_found(self): + """Scan a destination package that do not exist""" + line = self.one_line_picking.move_line_ids + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + # this bin is used for the other picking + "barcode": "⌿", + "quantity": line.reserved_uom_qty, + }, + ) + line_data = self._line_data(line) + line_data["qty_done"] = 10 + self.assert_response( + response, + next_state="scan_destination", + data=line_data, + message={ + "message_type": "error", + "body": "Bin {} doesn't exist".format("⌿"), + }, + ) + + def test_scan_destination_pack_quantity_more(self): + """Pick more units than expected""" + line = self.one_line_picking.move_line_ids + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.reserved_uom_qty + 1, + }, + ) + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line, qty_done=11.0), + message={ + "message_type": "error", + "body": "You must not pick more than {} units.".format( + line.reserved_uom_qty + ), + }, + ) + + def test_scan_destination_pack_quantity_less(self): + """Pick less units than expected""" + line = self.one_line_picking.move_line_ids + quant = self.env["stock.quant"].search( + [ + ("location_id", "=", line.location_id.id), + ("product_id", "=", line.product_id.id), + ] + ) + quant.ensure_one() + self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}]) + + # when we pick less quantity than expected, the line is split + # and the user is proposed to pick the next line for the remaining + # quantity + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.reserved_uom_qty - 3, + }, + ) + new_line = self.one_line_picking.move_line_ids - line + + self.assert_response( + response, + next_state="start_line", + data=self._line_data(new_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + line.qty_done, line.product_id.display_name, self.bin1.name + ), + }, + ) + + self.assertRecordValues( + line, + [{"qty_done": 7, "result_package_id": self.bin1.id, "reserved_uom_qty": 7}], + ) + self.assertRecordValues( + new_line, + [{"qty_done": 0, "result_package_id": False, "reserved_uom_qty": 3}], + ) + # the reserved quantity on the quant must stay the same + self.assertRecordValues(quant, [{"quantity": 40.0, "reserved_quantity": 20.0}]) + + def test_scan_destination_pack_zero_check_activated(self): + """Location will be emptied, have to go to zero check""" + # ensure that the location used for the test will contain only what we want + self.zero_check_location = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "ZeroCheck", + "location_id": self.stock_location.id, + "barcode": "ZEROCHECK", + } + ) + ) + line = self.one_line_picking.move_line_ids + location, product, qty = ( + self.zero_check_location, + line.product_id, + line.reserved_uom_qty, + ) + self.one_line_picking.do_unreserve() + + # ensure we have activated the zero check + self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = True + # Update the quantity in the location to be equal to the line's + # so when scan_destination_pack sets the qty_done, the planned + # qty should be zero and trigger a zero check + self._update_qty_in_location(location, product, qty) + # Reserve goods (now the move line has the expected source location) + self.one_line_picking.move_ids.location_id = location + self.one_line_picking.action_assign() + line = self.one_line_picking.move_line_ids + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.reserved_uom_qty, + }, + ) + + self.assert_response( + response, + next_state="zero_check", + data={ + "id": line.id, + "location_src": self.data.location(line.location_id), + "batch": self.data.picking_batch(self.batch), + }, + ) + + def test_scan_destination_pack_zero_check_disabled(self): + """Location will be emptied, no zero check, continue""" + line = self.one_line_picking.move_line_ids + # ensure we have deactivated the zero check + self.one_line_picking.picking_type_id.sudo().shopfloor_zero_check = False + # Update the quantity in the location to be equal to the line's + # so when scan_destination_pack sets the qty_done, the planned + # qty should be zero and trigger a zero check + self._update_qty_in_location( + line.location_id, line.product_id, line.reserved_uom_qty + ) + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.bin1.name, + "quantity": line.reserved_uom_qty, + }, + ) + + next_line = self.two_lines_picking.move_line_ids[0] + # continue to the next one, no zero check + self.assert_response( + response, + next_state="start_line", + data=self._line_data(next_line), + message={ + "message_type": "success", + "body": "{} {} put in {}".format( + line.qty_done, line.product_id.display_name, self.bin1.name + ), + }, + ) diff --git a/shopfloor/tests/test_cluster_picking_scan_destination_no_prefill_qty.py b/shopfloor/tests/test_cluster_picking_scan_destination_no_prefill_qty.py new file mode 100644 index 0000000000..24b077c76c --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_scan_destination_no_prefill_qty.py @@ -0,0 +1,115 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingCommonCase + +# pylint: disable=missing-return + + +class ClusterPickingScanDestinationPackPrefillQtyCase(ClusterPickingCommonCase): + """Tests covering the /scan_destination_pack endpoint + + With the no prefill quantity option set + + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.menu.sudo().no_prefill_qty = True + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ], + [cls.BatchProduct(product=cls.product_a, quantity=10)], + ] + ) + cls.one_line_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_ids) == 1 + ) + cls.two_lines_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_ids) == 2 + ) + + cls.bin1 = cls.env["stock.quant.package"].create({}) + cls.bin2 = cls.env["stock.quant.package"].create({}) + + cls._simulate_batch_selected(cls.batch) + + def test_scan_destination_pack_increment_with_product(self): + """Check quantity increment by scanning the product.""" + line = self.batch.picking_ids.move_line_ids[0] + previous_qty_done = line.qty_done + for qty_done in range(1, 2): + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": line.product_id.barcode, + "quantity": qty_done, + }, + ) + # Ensure the qty has not changed. + self.assertEqual(line.qty_done, previous_qty_done) + + expected_qty_done = qty_done + 1 + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line, qty_done=expected_qty_done), + ) + + def test_scan_destination_pack_increment_with_wrong_product(self): + """Check quantity is not incremented by scanning the wrong product.""" + line = self.batch.picking_ids.move_line_ids[0] + previous_qty_done = line.qty_done + qty_done = 2 + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": self.product_b.barcode, + "quantity": qty_done, + }, + ) + # Ensure the qty has not changed. + self.assertEqual(line.qty_done, previous_qty_done) + + expected_qty_done = qty_done + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line, qty_done=expected_qty_done), + message=self.service.msg_store.bin_not_found_for_barcode( + self.product_b.barcode + ), + ) + + def test_scan_destination_pack_increment_with_packaging(self): + """Check quantity incremented by scanning the packaging.""" + line = self.batch.picking_ids.move_line_ids[0] + previous_qty_done = line.qty_done + packaging = self.product_a_packaging + qty_done = 2 + response = self.service.dispatch( + "scan_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "move_line_id": line.id, + "barcode": packaging.barcode, + "quantity": qty_done, + }, + ) + # Ensure the qty has not changed in the record. + self.assertEqual(line.qty_done, previous_qty_done) + + expected_qty_done = qty_done + packaging.qty + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line, qty_done=expected_qty_done), + ) diff --git a/shopfloor/tests/test_cluster_picking_scan_line.py b/shopfloor/tests/test_cluster_picking_scan_line.py new file mode 100644 index 0000000000..be76f00426 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_scan_line.py @@ -0,0 +1,402 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingLineCommonCase + + +class ClusterPickingScanLineCase(ClusterPickingLineCommonCase): + """Tests covering the /scan_line endpoint + + After a batch has been selected and the user confirmed they are + working on it. + + User scans something and the scan_line endpoints validates they + scanned the proper thing to pick. + """ + + def _scan_line_ok(self, line, scanned, expected_qty_done=1): + batch = line.picking_id.batch_id + previous_qty_done = line.qty_done + response = self.service.dispatch( + "scan_line", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": scanned, + }, + ) + # For any barcode scanned, the quantity done is set in + # the response data to fully done but the record is not updated. + # We ensure the qty has not changed in the record. + self.assertEqual(line.qty_done, previous_qty_done) + + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data(line, qty_done=expected_qty_done), + ) + + def _scan_line_error(self, line, scanned, message, sublocation=None): + batch = line.picking_id.batch_id + response = self.service.dispatch( + "scan_line", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": scanned, + }, + ) + kw = {"sublocation": self.data.location(sublocation)} if sublocation else {} + self.assert_response( + response, + next_state="start_line", + data=self._line_data(line, **kw), + message=message, + ) + + def test_scan_line_pack_ok(self): + """Scan to check if user picks the correct pack for current line""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.package_id.name) + + def test_scan_line_product_ok(self): + """Scan to check if user picks the correct product for current line""" + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.product_id.barcode) + + def test_scan_line_lot_ok(self): + """Scan to check if user picks the correct lot for current line""" + self.product_a.tracking = "lot" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.lot_id.name) + + def test_scan_line_serial_ok(self): + """Scan to check if user picks the correct serial for current line""" + self.product_a.tracking = "serial" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.lot_id.name) + + def test_scan_line_error_product_tracked(self): + """Scan a product tracked by lot, must scan the lot. + + If for the same product there is multiple lots in the location, + the user must scan the lot. + """ + self.product_a.tracking = "lot" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + # Add another lot for the same product in the location + location = self.batch.picking_ids.location_id + new_lot = self.env["stock.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + self._update_qty_in_location(location, line.product_id, 2, lot=new_lot) + self._scan_line_error( + line, + line.product_id.barcode, + { + "message_type": "warning", + "body": "Product tracked by lot, please scan one.", + }, + ) + + def test_scan_line_lot_ok_only_one_in_location(self): + """Scan a product tracked by lot but no error. + + If only one lot for that product is in the location, it can + be safely picked up. + """ + self.product_a.tracking = "lot" + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.lot_id.name) + + def test_scan_line_product_error_several_packages(self): + """When we scan a product which is in more than one package, error""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move, in_package=True) + move._action_confirm(merge=False) + move._action_assign() + + self._scan_line_error( + line, + move.product_id.barcode, + { + "message_type": "warning", + "body": "This product is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_product_error_in_one_package_and_raw_same_location(self): + """Scan product which is both in a package and as raw in same location""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move) + move._action_confirm(merge=False) + move._action_assign() + move.move_line_ids[0].package_id = None + + self._scan_line_error( + line, + move.product_id.barcode, + { + "message_type": "warning", + "body": "This product is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_product_error_in_one_package_and_raw_different_location(self): + """Scan product which is both in a package and as raw in another location""" + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + move.location_id = line.location_id.copy() + self._fill_stock_for_moves(move) + move._action_confirm(merge=False) + move._action_assign() + move.move_line_ids[0].package_id = None + move.move_line_ids[0].location_id = line.location_id.copy() + self._scan_line_ok(line, move.product_id.barcode) + + def test_scan_line_lot_error_several_packages(self): + """When we scan a lot which is in more than one package, error""" + self._simulate_batch_selected(self.batch, in_package=True, in_lot=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move, in_lot=line.lot_id) + move._action_confirm(merge=False) + move._action_assign() + + self._scan_line_error( + line, + line.lot_id.name, + { + "message_type": "warning", + "body": "This lot is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_lot_error_in_one_package_and_unit(self): + """When we scan a lot which is in a package and as raw, error""" + self._simulate_batch_selected(self.batch, in_package=True, in_lot=True) + line = self.batch.picking_ids.move_line_ids + # create a second move line for the same product in a different + # package + move = line.move_id.copy() + self._fill_stock_for_moves(move, in_lot=line.lot_id) + move._action_confirm(merge=False) + move._action_assign() + self._scan_line_error( + line, + line.lot_id.name, + { + "message_type": "warning", + "body": "This lot is part of multiple" + " packages, please scan a package.", + }, + ) + + def test_scan_line_location_ok_single_package(self): + """Scan to check if user scans a correct location for current line + + If there is only one single package in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) + + def test_scan_line_location_ok_single_product(self): + """Scan to check if user scans a correct location for current line + + If there is only one single product in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) + + def test_scan_line_location_ok_single_lot(self): + """Scan to check if user scans a correct location for current line + + If there is only one single lot in the location, there is no + ambiguity so we can use it. + """ + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + self._scan_line_ok(line, line.location_id.barcode) + + def test_scan_line_location_error_several_package(self): + """Scan to check if user scans a correct location for current line + + If there are several packages in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch, in_package=True) + line = self.batch.picking_ids.move_line_ids + location = line.location_id + pack_1 = line.package_id + # add a second package for another product in the location + self._create_package_in_location( + location, [self.PackageContent(self.product_b, 10, lot=None)] + ) + # it leads to an error, but now the location is kept as working location + self._scan_line_error( + line, + location.barcode, + { + "message_type": "warning", + "body": "Several packages found in Stock, please scan a package.", + }, + sublocation=location, + ) + # scanning the package works + self._scan_line_ok(line, pack_1.name) + + def test_scan_line_location_error_several_products(self): + """Scan to check if user scans a correct location for current line + + If there are several products in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch) + line = self.batch.picking_ids.move_line_ids + location = line.location_id + # add a second product in the location, leads to an error + self._update_qty_in_location(location, self.product_b, 10) + self._scan_line_error( + line, + location.barcode, + { + "message_type": "warning", + "body": "Several products found in Stock, please scan a product.", + }, + sublocation=location, + ) + self._scan_line_ok(line, self.product_a.barcode) + + def test_scan_line_location_error_several_lots(self): + """Scan to check if user scans a correct location for current line + + If there are several lots in the location, user has to scan one. + """ + self._simulate_batch_selected(self.batch, in_lot=True) + line = self.batch.picking_ids.move_line_ids + location = line.location_id + # add a 2nd lot in the same location + lot_2 = ( + self.env["stock.lot"] + .sudo() + .create( + { + "name": "LOT_2nd", + "product_id": line.product_id.id, + "company_id": self.env.company.id, + } + ) + ) + self._update_qty_in_location(location, line.product_id, 10, lot=lot_2) + # leads to an error + self._scan_line_error( + line, + location.barcode, + { + "message_type": "warning", + "body": "Several lots found in Stock, please scan a lot.", + }, + sublocation=location, + ) + self._scan_line_ok(line, line.lot_id.name) + + def test_scan_line_error_wrong_package(self): + """Wrong package scanned""" + self._simulate_batch_selected(self.batch, in_package=True) + pack = self.env["stock.quant.package"].sudo().create({}) + self._scan_line_error( + self.batch.picking_ids.move_line_ids, + pack.name, + {"message_type": "error", "body": "Wrong pack."}, + ) + + def test_scan_line_error_wrong_product(self): + """Wrong product scanned""" + self._simulate_batch_selected(self.batch, in_package=True) + product = ( + self.env["product.product"] + .sudo() + .create( + { + "name": "Wrong", + "barcode": "WRONGPRODUCT", + } + ) + ) + self._scan_line_error( + self.batch.picking_ids.move_line_ids, + product.barcode, + {"message_type": "error", "body": "Wrong product."}, + ) + + def test_scan_line_error_wrong_lot(self): + """Wrong product scanned""" + self._simulate_batch_selected(self.batch, in_package=True) + lot = ( + self.env["stock.lot"] + .sudo() + .create( + { + "name": "WRONGLOT", + "product_id": self.batch.picking_ids.move_line_ids[0].product_id.id, + "company_id": self.env.company.id, + } + ) + ) + self._scan_line_error( + self.batch.picking_ids.move_line_ids, + lot.name, + {"message_type": "error", "body": "Wrong lot."}, + ) + + def test_scan_line_error_wrong_location(self): + """Wrong product scanned""" + self._simulate_batch_selected(self.batch, in_package=True) + location = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Wrong", + "barcode": "WRONGLOCATION", + } + ) + ) + self._scan_line_error( + self.batch.picking_ids.move_line_ids, + location.barcode, + {"message_type": "error", "body": "Wrong location."}, + ) + + def test_scan_line_error_not_found(self): + """Nothing found for the barcode""" + self._simulate_batch_selected(self.batch, in_package=True) + self._scan_line_error( + self.batch.picking_ids.move_line_ids, + "NO_EXISTING_BARCODE", + {"message_type": "error", "body": "Barcode not found"}, + ) diff --git a/shopfloor/tests/test_cluster_picking_scan_line_location_or_pack_first.py b/shopfloor/tests/test_cluster_picking_scan_line_location_or_pack_first.py new file mode 100644 index 0000000000..31868a0f38 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_scan_line_location_or_pack_first.py @@ -0,0 +1,114 @@ +# Copyright 2023 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingLineCommonCase + +# pylint: disable=missing-return + + +class ClusterPickingScanLineLocationOrPackFirstCase(ClusterPickingLineCommonCase): + """Tests covering the /scan_line endpoint + + When the scan location or pack frist option enabled. + + """ + + def setUp(self): + super().setUp() + self.menu.sudo().scan_location_or_pack_first = True + + def _scan_line_error(self, line, scanned, message, sublocation=None): + batch = line.picking_id.batch_id + response = self.service.dispatch( + "scan_line", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": scanned, + "sublocation_id": sublocation.id if sublocation else None, + }, + ) + kw = {"sublocation": self.data.location(sublocation)} if sublocation else {} + self.assert_response( + response, + next_state="start_line", + data=self._line_data(line, scan_location_or_pack_first=True, **kw), + message=message, + ) + return response + + def _scan_line_ok(self, line, scanned, expected_qty_done=1, sublocation_id=None): + batch = line.picking_id.batch_id + response = self.service.dispatch( + "scan_line", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": scanned, + "sublocation_id": sublocation_id, + }, + ) + self.assert_response( + response, + next_state="scan_destination", + data=self._line_data( + line, qty_done=expected_qty_done, scan_location_or_pack_first=True + ), + ) + + def test_scan_line_product_ask_for_package(self): + """Check scanning the product first will request to scan the package. + + This is if the line being worked on as a package. + + """ + self._simulate_batch_selected(self.batch, in_package=True, in_lot=False) + line = self.batch.picking_ids.move_line_ids + location = self.batch.picking_ids.location_id + self._update_qty_in_location(location, self.product_a, 2) + self._scan_line_error( + line, + line.product_id.barcode, + self.msg_store.line_has_package_scan_package(), + ) + + def test_scan_line_product_ask_for_location(self): + """Check scanning the product first will request to scan the location. + + That is if the product on the move line is tracked by lot. + + """ + self._simulate_batch_selected(self.batch, in_package=False, in_lot=True) + line = self.batch.picking_ids.move_line_ids + location = self.batch.picking_ids.location_id + self._update_qty_in_location(location, self.product_a, 2) + self._scan_line_error( + line, line.product_id.barcode, self.msg_store.scan_the_location_first() + ) + + def test_scan_line_location_ask_for_package(self): + """Check scanning location for a line with pack will ask to scan the pack.""" + self._simulate_batch_selected(self.batch, in_package=True, in_lot=False) + line = self.batch.picking_ids.move_line_ids + location = self.batch.picking_ids.location_id + self._update_qty_in_location(location, self.product_a, 2) + self._scan_line_error( + line, + line.location_id.barcode, + self.msg_store.line_has_package_scan_package(), + sublocation=location, + ) + + def test_scan_line_location_with_multiple_product(self): + """Check scanning a location then a product without package. + + When there is multiple product in the location and the location is scanned, + The user needs to scan the product but the system does not remember the location ? + + """ + self._simulate_batch_selected(self.batch, in_package=False, in_lot=False) + line = self.batch.picking_ids.move_line_ids + location = self.batch.picking_ids.location_id + self._update_qty_in_location(location, self.product_a, 2) + self._update_qty_in_location(location, self.product_b, 2) + self._scan_line_ok(line, line.product_id.barcode, 1.0, location.id) diff --git a/shopfloor/tests/test_cluster_picking_scan_line_no_prefill_qty.py b/shopfloor/tests/test_cluster_picking_scan_line_no_prefill_qty.py new file mode 100644 index 0000000000..e669e0182e --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_scan_line_no_prefill_qty.py @@ -0,0 +1,70 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingLineCommonCase + + +class ClusterPickingScanLineNoPrefillQtyCase(ClusterPickingLineCommonCase): + """Tests covering the /scan_line endpoint + + With the no prefill quantity option set + + """ + + @classmethod + def _enable_no_prefill(cls): + cls.menu.sudo().no_prefill_qty = True + cls.picking = cls.batch.picking_ids + cls.line = cls.picking.move_line_ids + cls.line.reserved_uom_qty = 3 + + def _assert_qty_done(self, line, scanned, expected_qty_done): + batch = line.picking_id.batch_id + response = self.service.dispatch( + "scan_line", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": scanned, + }, + ) + qty_done = response["data"]["scan_destination"]["qty_done"] + self.assertEqual(qty_done, expected_qty_done) + + def test_scan_line_package_no_prefill_set(self): + """Check scanning a package when no_prefill_qty is enabled.""" + self._simulate_batch_selected(self.batch, in_package=True) + self._enable_no_prefill() + self._assert_qty_done(self.line, self.line.package_id.name, 0.0) + + def test_scan_line_packaging_no_prefill_set(self): + """Check scanning a packaging when no_prefill_qty is enabled.""" + self._simulate_batch_selected(self.batch, in_package=False) + self._enable_no_prefill() + packaging = self.env.ref( + "stock_storage_type.product_product_9_packaging_4_cardbox" + ) + packaging.sudo().write( + {"product_id": self.line.product_id.id, "barcode": "cute-pack"} + ) + # The quantity of the packaging is incremented + self._assert_qty_done(self.line, packaging.barcode, packaging.qty) + + def test_scan_line_product_no_prefill_set(self): + """Check qty done when product is scanned and no_prefill_qty is enabled""" + self._simulate_batch_selected(self.batch) + self._enable_no_prefill() + self._assert_qty_done(self.line, self.line.product_id.barcode, 1.0) + + def test_scan_line_lot_no_prefill_set(self): + """Check qty done when lot is scanned and no_prefill_qty is enabled""" + self.product_a.tracking = "lot" + self._simulate_batch_selected(self.batch, in_lot=True) + self._enable_no_prefill() + self._assert_qty_done(self.line, self.line.lot_id.name, 1.0) + + def test_scan_line_location_no_prefill_set(self): + """Check qty done when location is scanned and no_prefill_qty is enabled""" + self._simulate_batch_selected(self.batch, in_package=True) + self._enable_no_prefill() + self._assert_qty_done(self.line, self.line.location_id.barcode, 0) diff --git a/shopfloor/tests/test_cluster_picking_select.py b/shopfloor/tests/test_cluster_picking_select.py new file mode 100644 index 0000000000..5d2a6c9efa --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_select.py @@ -0,0 +1,387 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields + +from .test_cluster_picking_base import ClusterPickingCommonCase + +# pylint: disable=missing-return + + +class ClusterPickingSelectionCase(ClusterPickingCommonCase): + """Tests covering the selection of picking batches + + Endpoints: + + * /cluster_picking/find_batch + * /cluster_picking/list_batch + * /cluster_picking/select + * /cluster_picking/unassign + + These endpoints interact with a list of picking batches. + The other endpoints that interact with a single batch (after selection) + are handled in other classes. + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + # drop base demo data and create our own batches to work with + old_batchs = cls.env["stock.picking.batch"].search([]) + old_batchs.write({"state": "draft"}) + old_batchs.unlink() + cls.batch1 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=3)]] + ) + cls.batch2 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=3)]] + ) + cls.batch3 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=3)]] + ) + cls.batch4 = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=3)]] + ) + + def _add_stock_and_assign_pickings_for_batches(self, batches): + pickings = batches.mapped("picking_ids") + self._fill_stock_for_moves(pickings.mapped("move_ids")) + pickings.action_assign() + + def test_find_batch_in_progress_current_user(self): + """Find an in-progress batch assigned to the current user""" + # Simulate the client asking a batch by clicking on "get work" + self._add_stock_and_assign_pickings_for_batches( + self.batch1 | self.batch2 | self.batch3 + ) + self.batch3.user_id = self.env.uid + self.batch3.action_confirm() # set to in progress + response = self.service.dispatch("find_batch") + + # we expect to find batch 3 as it's assigned to the current + # user and in progress (first priority) + data = self.data.picking_batch(self.batch3, with_pickings=True) + self.assert_response( + response, + next_state="confirm_start", + data=data, + ) + + def test_find_batch_assigned(self): + """Find a draft batch assigned to the current user""" + # batches must have all their pickings available to be selected + self._add_stock_and_assign_pickings_for_batches( + self.batch1 | self.batch2 | self.batch3 + ) + # batch2 in draft but assigned to the current user should be + # selected before the others + self.batch2.user_id = self.env.uid + response = self.service.dispatch("find_batch") + + # The endpoint starts the batch + self.assertEqual(self.batch2.state, "in_progress") + + # we expect to find batch 2 as it's assigned to the current user + data = self.data.picking_batch(self.batch2, with_pickings=True) + self.assert_response( + response, + next_state="confirm_start", + data=data, + ) + + def test_find_batch_unassigned_draft(self): + """Find a draft batch""" + # batches must have all their pickings available to be selected + self._add_stock_and_assign_pickings_for_batches(self.batch2 | self.batch3) + # batch1 has not all pickings available, so the first draft + # is batch2, should be selected + response = self.service.dispatch("find_batch") + + # The endpoint starts the batch and assign it to self + self.assertEqual(self.batch2.user_id, self.env.user) + self.assertEqual(self.batch2.state, "in_progress") + + # we expect to find batch 2 as it's the first one with all pickings + # available + data = self.data.picking_batch(self.batch2, with_pickings=True) + self.assert_response( + response, + next_state="confirm_start", + data=data, + ) + + def test_find_batch_not_found(self): + """No batch to work on""" + # No batch match the rules to work on them, because + # their pickings are not available + response = self.service.dispatch("find_batch") + self.assert_response( + response, + next_state="start", + message={ + "message_type": "info", + "body": "No more work to do, please create a new batch transfer", + }, + ) + + def test_list_batch(self): + """List all available batches""" + # batches must have all their pickings available to be selected + self._add_stock_and_assign_pickings_for_batches( + self.batch1 | self.batch2 | self.batch3 + ) + self.batch1.write({"state": "in_progress", "user_id": self.env.uid}) + self.batch2.write( + {"state": "in_progress", "user_id": self.env.ref("base.user_demo").id} + ) + self.batch3.write({"state": "draft", "user_id": False}) + + self.assertEqual( + self.env["stock.picking.batch"].search([]), + self.batch1 + self.batch2 + self.batch3 + self.batch4, + ) + # Simulate the client asking the list of batches + response = self.service.dispatch("list_batch") + self.assert_response( + response, + next_state="manual_selection", + data={ + "size": 2, + "records": self.data.picking_batches(self.batch1 + self.batch3), + }, + ) + + def test_select_in_progress_assigned(self): + """Select an in-progress batch assigned to the current user""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + self.batch1.write({"state": "in_progress", "user_id": self.env.uid}) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + data = self.data.picking_batch(self.batch1) + # we don't care in these tests, 'find_batch' tests them already + data["pickings"] = self.ANY + self.assert_response( + response, + next_state="confirm_start", + data=data, + ) + + def test_select_draft_assigned(self): + """Select a draft batch assigned to the current user""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + self.batch1.write({"user_id": self.env.uid}) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + # The endpoint starts the batch and assign it to self + self.assertEqual(self.batch1.user_id, self.env.user) + self.assertEqual(self.batch1.state, "in_progress") + data = self.data.picking_batch(self.batch1) + # we don't care in these tests, 'find_batch' tests them already + data["pickings"] = self.ANY + self.assert_response( + response, + next_state="confirm_start", + data=data, + ) + + def test_select_draft_unassigned(self): + """Select a draft batch not assigned to a user""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + # The endpoint starts the batch and assign it to self + self.assertEqual(self.batch1.user_id, self.env.user) + self.assertEqual(self.batch1.state, "in_progress") + data = self.data.picking_batch(self.batch1) + # we don't care in these tests, 'find_batch' tests them already + data["pickings"] = self.ANY + self.assert_response( + response, + next_state="confirm_start", + data=data, + ) + + def test_select_not_exists(self): + """Select a draft that does not exist""" + batch_id = self.batch1.id + self.batch1.state = "draft" + self.batch1.unlink() + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": batch_id} + ) + self.assert_response( + response, + next_state="manual_selection", + message={ + "message_type": "warning", + "body": "This batch cannot be selected.", + }, + data={"size": 0, "records": []}, + ) + + def test_select_already_assigned(self): + """Select a draft that does not exist""" + self._add_stock_and_assign_pickings_for_batches(self.batch1) + self.batch1.write( + {"state": "in_progress", "user_id": self.env.ref("base.user_demo").id} + ) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "select", params={"picking_batch_id": self.batch1.id} + ) + self.assert_response( + response, + next_state="manual_selection", + message={ + "message_type": "warning", + "body": "This batch cannot be selected.", + }, + data={"size": 0, "records": []}, + ) + + def test_unassign_batch(self): + """User cancels after selecting a batch, unassign it""" + self._simulate_batch_selected(self.batch1) + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "unassign", params={"picking_batch_id": self.batch1.id} + ) + self.assertEqual(self.batch1.state, "draft") + self.assertFalse(self.batch1.user_id) + self.assert_response(response, next_state="start") + + def test_unassign_batch_not_exists(self): + """User cancels after selecting a batch deleted meanwhile""" + batch_id = self.batch1.id + self.batch1.state = "draft" + self.batch1.unlink() + # Simulate the client selecting the batch in a list + response = self.service.dispatch( + "unassign", params={"picking_batch_id": batch_id} + ) + self.assert_response(response, next_state="start") + + +class ClusterPickingSelectedCase(ClusterPickingCommonCase): + """Tests covering endpoints working on a single picking batch + + After a batch has been selected, by the tests covered in + ``ClusterPickingSelectionCase``. + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=1)]] + ) + cls._simulate_batch_selected(cls.batch, in_package=True) + cls.batch2 = cls._create_picking_batch( + [ + [cls.BatchProduct(product=cls.product_a, quantity=1)], + [cls.BatchProduct(product=cls.product_a, quantity=1)], + [cls.BatchProduct(product=cls.product_b, quantity=1)], + [cls.BatchProduct(product=cls.product_b, quantity=1)], + ] + ) + cls._simulate_batch_selected(cls.batch2, in_package=True) + + def test_lines_order(self): + batch = self.batch2 + picking1 = batch.picking_ids[0] + today = fields.Datetime.today() + future = fields.Datetime.add( + fields.Datetime.end_of(fields.Datetime.today(), "day"), days=2 + ) + # Change dates + move1 = picking1.move_ids[0] + move1_line = move1.move_line_ids[0] + move1.write({"date": today}) + (batch.picking_ids.move_ids - move1).write({"date": future}) + + move_lines = self.service._lines_for_picking_batch(batch) + order_mapping = {line: i for i, line in enumerate(move_lines)} + + # Today line comes first + self.assertEqual(order_mapping[move1_line], 0) + # swap dates + move1.write({"date": future}) + (batch.picking_ids.move_ids - move1).write({"date": today}) + + move_lines = self.service._lines_for_picking_batch(batch) + order_mapping = {line: i for i, line in enumerate(move_lines)} + self.assertEqual(order_mapping[move1_line], len(move_lines) - 1) + # TODO: we should test all the combo of keys affecting sorting. + + def test_confirm_start_ok(self): + """User confirms she starts the selected picking batch (happy path)""" + # batch1 was already selected, we only need to confirm the selection + batch = self.batch + self.assertEqual(batch.state, "in_progress") + picking = batch.picking_ids[0] + first_move_line = picking.move_line_ids[0] + self.assertTrue(first_move_line) + # A package exists on the move line, because the quant created + # by ``_simulate_batch_selected`` has a package. + package = first_move_line.package_id + self.assertTrue(package) + + response = self.service.dispatch( + "confirm_start", params={"picking_batch_id": self.batch.id} + ) + data = self.data.move_line(first_move_line) + data["package_dest"] = None + data["picking"] = self.data.picking(picking) + data["batch"] = self.data.picking_batch(batch) + data["scan_location_or_pack_first"] = False + self.assert_response( + response, + data=data, + next_state="start_line", + ) + + def test_confirm_start_not_exists(self): + """User confirms she starts but batch has been deleted meanwhile""" + batch_id = self.batch.id + self.batch.state = "draft" + self.batch.unlink() + response = self.service.dispatch( + "confirm_start", params={"picking_batch_id": batch_id} + ) + self.assert_response( + response, + message={ + "message_type": "error", + "body": "The record you were working on does not exist anymore.", + }, + next_state="start", + ) + + def test_confirm_start_all_is_done(self): + """User confirms start but all lines are already done""" + # we want to jump to the start because there are no lines + # to process anymore, but we want to set pickings and + # picking batch to done if not done yet (because the process + # was interrupted for instance) + self._set_dest_package_and_done( + self.batch.mapped("picking_ids.move_line_ids"), + self.env["stock.quant.package"].create({}), + ) + self.batch.action_done() + response = self.service.dispatch( + "confirm_start", params={"picking_batch_id": self.batch.id} + ) + self.assert_response( + response, + next_state="start", + message={"body": "Batch Transfer complete", "message_type": "success"}, + ) + + # TODO: add a test for lines sorting diff --git a/shopfloor/tests/test_cluster_picking_skip.py b/shopfloor/tests/test_cluster_picking_skip.py new file mode 100644 index 0000000000..60c2d09520 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_skip.py @@ -0,0 +1,90 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingCommonCase + +# pylint: disable=missing-return + + +class ClusterPickingSkipLineCase(ClusterPickingCommonCase): + """Tests covering the /skip_line endpoint""" + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + # quants already existing are from demo data + cls.env["stock.quant"].sudo().search( + [("location_id", "=", cls.stock_location.id)] + ).unlink() + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=20), + ], + [ + cls.BatchProduct(product=cls.product_a, quantity=30), + cls.BatchProduct(product=cls.product_b, quantity=40), + ], + ] + ) + + def _skip_line(self, line, next_line=None): + response = self.service.dispatch( + "skip_line", + params={"picking_batch_id": self.batch.id, "move_line_id": line.id}, + ) + if next_line: + self.assert_response( + response, next_state="start_line", data=self._line_data(next_line) + ) + return response + + def test_skip_line(self): + # put one picking in another location + self.batch.picking_ids[1].location_id = self.shelf1 + self.batch.picking_ids[1].move_ids.location_id = self.shelf1 + # select batch + self._simulate_batch_selected(self.batch, in_package=True) + + # enforce names to have reliable sorting + self.stock_location.sudo().name = "LOC2" + self.shelf1.sudo().name = "LOC1" + all_lines = self.batch.picking_ids.move_line_ids + loc1_lines = all_lines.filtered(lambda line: (line.location_id == self.shelf1)) + loc2_lines = all_lines.filtered( + lambda line: (line.location_id == self.stock_location) + ) + # no line postponed yet + self.assertEqual( + all_lines.mapped("shopfloor_postponed"), [False, False, False, False] + ) + + # skip line from loc 1 + previous_priority = loc1_lines[0].shopfloor_priority + self._skip_line(loc1_lines[0], loc1_lines[1]) + self.assertEqual(loc1_lines[0].shopfloor_priority, previous_priority + 1) + loc1_lines.invalidate_recordset(["shopfloor_postponed"]) + self.assertTrue(loc1_lines[0].shopfloor_postponed) + + # 2nd line, next is 1st from 2nd location + self.assertFalse(loc1_lines[1].shopfloor_postponed) + self._skip_line(loc1_lines[1], loc2_lines[0]) + # Priority is now the current max + 1 + self.assertEqual( + loc1_lines[1].shopfloor_priority, loc1_lines[0].shopfloor_priority + 1 + ) + loc1_lines.invalidate_recordset(["shopfloor_postponed"]) + self.assertTrue(loc1_lines[1].shopfloor_postponed) + + # 3rd line, next is 4th + self.assertFalse(loc2_lines[0].shopfloor_postponed) + self._skip_line(loc2_lines[0], loc2_lines[1]) + self.assertEqual( + loc2_lines[0].shopfloor_priority, loc1_lines[1].shopfloor_priority + 1 + ) + loc1_lines.invalidate_recordset(["shopfloor_postponed"]) + self.assertTrue(loc2_lines[0].shopfloor_postponed) + + +# TODO tests for transitions to next line / no next lines, ... diff --git a/shopfloor/tests/test_cluster_picking_stock_issue.py b/shopfloor/tests/test_cluster_picking_stock_issue.py new file mode 100644 index 0000000000..d2af17e4b7 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_stock_issue.py @@ -0,0 +1,364 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingCommonCase + +# pylint: disable=missing-return + + +class ClusterPickingStockIssue(ClusterPickingCommonCase): + """Tests covering the /stock_issue endpoint""" + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + # quants already existing are from demo data + loc_ids = (cls.stock_location.id, cls.shelf1.id, cls.shelf2.id) + cls.env["stock.quant"].sudo().search([("location_id", "in", loc_ids)]).unlink() + cls.batch = cls._create_picking_batch( + [ + [cls.BatchProduct(product=cls.product_a, quantity=10)], + [cls.BatchProduct(product=cls.product_a, quantity=5)], + [cls.BatchProduct(product=cls.product_a, quantity=20)], + [cls.BatchProduct(product=cls.product_a, quantity=10)], + [cls.BatchProduct(product=cls.product_a, quantity=7)], + ] + ) + + cls.moves = cls.batch.picking_ids.move_ids.sorted("id") + cls.move1, cls.move2, cls.move3, cls.move4, cls.move5 = cls.moves + cls.batch_other = cls._create_picking_batch( + [[cls.BatchProduct(product=cls.product_a, quantity=30)]] + ) + cls.dest_package = cls.env["stock.quant.package"].create({}) + + def _stock_issue(self, line, next_line_func=None): + batch = line.picking_id.batch_id + response = self.service.dispatch( + "stock_issue", + params={"picking_batch_id": batch.id, "move_line_id": line.id}, + ) + # use a function/lambda to delay the read of the next line, + # when calling _stock_issue(), the move_line may not exist and + # be created during the call to the stock_issue service + if next_line_func: + self.assert_response( + response, + next_state="start_line", + data=self._line_data(next_line_func()), + ) + else: + self.assert_response( + response, + next_state="unload_all", + data=self._data_for_batch(self.batch, self.packing_location), + ) + return response + + def assert_location_qty_and_reserved( + self, location, expected_reserved_qty, lot=None + ): + quant_domain = [("location_id", "=", location.id)] + if lot: + quant_domain += [("lot_id", "=", lot.id)] + location_quants = self.env["stock.quant"].search(quant_domain) + self.assertEqual(sum(location_quants.mapped("quantity")), expected_reserved_qty) + self.assertEqual( + sum(location_quants.mapped("reserved_quantity")), expected_reserved_qty + ) + + def assert_stock_issue_inventories( + self, location, product, remaining_qty, lot=None + ): + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ] + if lot: + domain.append(("lot_id", "=", lot.id)) + quant = self.env["stock.quant"].search(domain, order="id desc", limit=1) + self.assertEqual(quant.quantity, remaining_qty) + self.assertFalse(quant.inventory_quantity_set) + + def test_stock_issue_with_other_batch(self): + self._update_qty_in_location(self.shelf1, self.product_a, 25) + # The other batch will reserve 25 in shelf 1, now empty + self.batch_other.picking_ids.action_assign() + + self._update_qty_in_location(self.shelf2, self.product_a, 100) + # and then, the other batch reserves 5 in shelf 2. + # We'll want to check that even if on our batch we have a stock issue, + # we never change anything in the batch of another operator. + self._simulate_batch_selected(self.batch_other, fill_stock=False) + self.assertEqual( + set(self.batch_other.picking_ids.mapped("state")), {"assigned"} + ) + + # At this point, we have a remaining quantity of 0 in shelf1 + # and 95 in shelf2. + + # all the moves of our batch should be reserved as we have enough + # stock + self._simulate_batch_selected(self.batch, fill_stock=False) + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + + # the operator could pick the 2 first lines of the batch + self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package) + self._set_dest_package_and_done(self.move2.move_line_ids, self.dest_package) + + # on the third move, the operator can't pick anymore in shelf1 + # because there is nothing inside, they declare a stock issue + self._stock_issue(self.move3.move_line_ids) + + self.assertRecordValues( + self.moves, + [ + {"state": "assigned"}, + {"state": "assigned"}, + {"state": "confirmed"}, + {"state": "confirmed"}, + {"state": "confirmed"}, + ], + ) + expected_reserved_qty = ( + self.move1.product_uom_qty + + self.move2.product_uom_qty + + sum( + self.batch_other.picking_ids.move_line_ids.filtered( + lambda l: l.location_id == self.shelf2 + ).mapped("reserved_uom_qty") + ) + ) + # we should have a quant with 20 quantity and 20 reserved + # (5 for the other batch and 15 qty_done in this batch) + self.assert_location_qty_and_reserved(self.shelf2, expected_reserved_qty) + self.assert_stock_issue_inventories( + self.shelf2, + self.move3.product_id, + expected_reserved_qty, + ) + + def test_stock_issue_several_move_lines(self): + self._update_qty_in_location(self.shelf1, self.product_a, 20) + # ensure these moves are reserved in shelf1 + self.move1._action_assign() + self.move2._action_assign() + + self._update_qty_in_location(self.shelf2, self.product_a, 100) + # reserve move3 first to ensure this one is reserved in both + # shelf1 and shelf2 + self.move3._action_assign() + + # all the remaining moves will be reserved in shelf2 + self._simulate_batch_selected(self.batch, fill_stock=False) + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + # The moves of our batch are reserved as: + self.assertEqual(self.move1.move_line_ids.location_id, self.shelf1) + self.assertEqual(self.move2.move_line_ids.location_id, self.shelf1) + self.assertEqual( + self.move3.move_line_ids.mapped("location_id"), self.shelf1 | self.shelf2 + ) + self.assertEqual(self.move4.move_line_ids.location_id, self.shelf2) + self.assertEqual(self.move5.move_line_ids.location_id, self.shelf2) + + line_shelf1 = self.move3.move_line_ids.filtered( + lambda l: l.location_id == self.shelf1 + ) + line_shelf2 = self.move3.move_line_ids.filtered( + lambda l: l.location_id == self.shelf2 + ) + + # pick the first 2 moves + self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package) + self._set_dest_package_and_done(self.move2.move_line_ids, self.dest_package) + # the operator could pick the first part of move3 in shelf1 + self._set_dest_package_and_done(line_shelf1, self.dest_package) + + # on the third move, the operator can't pick anymore in shelf1 + # because there is nothing inside, they declare a stock issue + self._stock_issue(line_shelf2) + + self.assertRecordValues( + self.moves, + [ + # move 1 and 2 aren't touched: they are in another location + {"state": "assigned"}, + {"state": "assigned"}, + {"state": "partially_available"}, + {"state": "confirmed"}, + {"state": "confirmed"}, + ], + ) + self.assertRecordValues( + # check that the other move line of the move was not altered + line_shelf1, + [ + { + "location_id": self.shelf1.id, + "qty_done": 5.0, + "result_package_id": self.dest_package.id, + } + ], + ) + self.assertFalse(line_shelf2.exists()) + # the quantity in shelf1 should be the original one since we didn't have + # a stock issue here + self.assert_location_qty_and_reserved(self.shelf1, 20) + # since we declared the stock issue without picking anything, its + # quantity should be zero + self.assert_location_qty_and_reserved(self.shelf2, 0) + self.assert_stock_issue_inventories(self.shelf2, self.move3.product_id, 0) + + def test_stock_issue_lot(self): + lot_a = self.env["stock.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + lot_b = self.env["stock.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + self._update_qty_in_location( + self.shelf2, + self.product_a, + self.move1.product_uom_qty + self.move5.product_uom_qty, + lot=lot_a, + ) + # ensure that move 1 and 5 take lot_a (10 + 7 units), so all of them + self.move1._action_assign() + self.move5._action_assign() + # add stock for the rest of the moves + self._update_qty_in_location(self.shelf2, self.product_a, 100, lot=lot_b) + # reserve the remaining moves + self._simulate_batch_selected(self.batch, fill_stock=False) + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + + # the operator could pick the 3 first lines of the batch + # move move1 with lot a + self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package) + # move move2 with lot b + self._set_dest_package_and_done(self.move2.move_line_ids, self.dest_package) + + # on the third move, the operator can't pick anymore in the location, + # because there is nothing inside, they declare a stock issue + self._stock_issue( + self.move3.move_line_ids, next_line_func=lambda: self.move5.move_line_ids + ) + + self.assertRecordValues( + self.moves, + [ + # still reserved because using lot a + {"state": "assigned"}, + # still reserved because qty_done > 0 + {"state": "assigned"}, + # unreserved by the stock issue + {"state": "confirmed"}, + # collaterally unreserved by the stock issue (same lot as the + # stock issue) + {"state": "confirmed"}, + # still reserved because using lot a + {"state": "assigned"}, + ], + ) + # check the qty including lot a and lot b + total_reserved_qty = ( + self.move1.product_uom_qty + + self.move2.product_uom_qty + + self.move5.product_uom_qty + ) + self.assert_location_qty_and_reserved(self.shelf2, total_reserved_qty) + # this is the only product reserved for lot_b + expected_reserved_qty = self.move2.product_uom_qty + self.assert_location_qty_and_reserved( + self.shelf2, expected_reserved_qty, lot=lot_b + ) + self.assert_stock_issue_inventories( + self.shelf2, + self.move3.product_id, + expected_reserved_qty, + lot=lot_b, + ) + + def test_stock_issue_reserve_elsewhere(self): + self._update_qty_in_location(self.shelf1, self.product_a, 100) + self._simulate_batch_selected(self.batch, fill_stock=False) + # now, everything is reserved in shelf1 as we had enough stock + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + + # put stock in shelf2, so we can test the outcome: goods should be + # reserved in shelf2 after a stock issue + self._update_qty_in_location(self.shelf2, self.product_a, 100) + + # the operator picks the first line + self._set_dest_package_and_done(self.move1.move_line_ids, self.dest_package) + + # and has a stock issue on the second line + # because there is nothing inside, they declare a stock issue + self._stock_issue( + self.move2.move_line_ids, next_line_func=lambda: self.move2.move_line_ids + ) + + # the inventory should have been done for shelf1, and all the remaining + # moves after move1 (already picked) should have been reserved in + # shelf2 + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + self.assertEqual(self.move1.move_line_ids.location_id, self.shelf1) + # all the following moves have been reserved in shelf2 as we still have + # stock there + self.assertEqual( + (self.move2 | self.move3 | self.move4 | self.move5).mapped( + "move_line_ids.location_id" + ), + self.shelf2, + ) + + def test_stock_issue_similar_move_with_picked_line(self): + """Stock issue on the remaining of a line on partial move + + We have a move with 10 units. + 2 are reserved in a package. The remaining in another package. + We pick 1 of the first package and put it in a bin. + A new move line of 1 is created to pick in the first package: we + declare a stock out on it. + The first move line must be untouched, the second line for the remaining + should pick one more item in the other package. + """ + package1 = self.env["stock.quant.package"].create({"name": "PACKAGE_1"}) + package2 = self.env["stock.quant.package"].create({"name": "PACKAGE_2"}) + self._update_qty_in_location(self.shelf1, self.product_a, 2, package=package1) + self._update_qty_in_location(self.shelf1, self.product_a, 200, package=package2) + self.move1._action_assign() + self.move2._action_assign() + self.move3._action_assign() + self._simulate_batch_selected(self.batch, fill_stock=False) + self.assertEqual(set(self.batch.picking_ids.mapped("state")), {"assigned"}) + + pick_line1, pick_line2 = self.move1.move_line_ids + new_line, __ = pick_line1._split_qty_to_be_done(1) + self._set_dest_package_and_done(pick_line1, self.dest_package) + + self.assertEqual(pick_line1.reserved_qty, 1.0) + self.assertEqual(new_line.reserved_qty, 1.0) + self.assertEqual(pick_line2.reserved_qty, 8.0) + # on the third move, the operator can't pick anymore in shelf1 + # because there is nothing inside, they declare a stock issue + self._stock_issue(new_line, next_line_func=lambda: pick_line2) + + self.assertRecordValues( + # check that the first move line of the move was not altered + pick_line1, + [ + { + "location_id": self.shelf1.id, + "qty_done": 1.0, + "result_package_id": self.dest_package.id, + } + ], + ) + # the line on which we declared stock out does not exists + self.assertFalse(new_line.exists()) + # the second line to pick has been raised to 9 instead of 8 + # initially, to compensate the stock out + self.assertEqual(pick_line2.reserved_qty, 9.0) + + # quant with stock out has been updated + self.assertEqual(package1.quant_ids.quantity, 1.0) diff --git a/shopfloor/tests/test_cluster_picking_unload.py b/shopfloor/tests/test_cluster_picking_unload.py new file mode 100644 index 0000000000..357922b1b8 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_unload.py @@ -0,0 +1,911 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_cluster_picking_base import ClusterPickingCommonCase + +# pylint: disable=missing-return + + +class ClusterPickingUnloadingCommonCase(ClusterPickingCommonCase): + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + + # activate the computation of this field, so we have a chance to + # transition to the 'show completion info' popup. + cls.picking_type.sudo().display_completion_info = True + + cls.batch = cls._create_picking_batch( + [ + [ + cls.BatchProduct(product=cls.product_a, quantity=10), + cls.BatchProduct(product=cls.product_b, quantity=10), + ], + [cls.BatchProduct(product=cls.product_a, quantity=10)], + ] + ) + cls._simulate_batch_selected(cls.batch) + + cls.one_line_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_ids) == 1 + ) + cls.two_lines_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_ids) == 2 + ) + two_lines_product_a = cls.two_lines_picking.move_line_ids.filtered( + lambda line: line.product_id == cls.product_a + ) + two_lines_product_b = cls.two_lines_picking.move_line_ids - two_lines_product_a + # force order of move lines to use in tests + cls.move_lines = ( + cls.one_line_picking.move_line_ids + + two_lines_product_a + + two_lines_product_b + ) + + cls.bin1 = cls.env["stock.quant.package"].create({}) + cls.bin2 = cls.env["stock.quant.package"].create({}) + cls.packing_a_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Packing A", + "barcode": "Packing-A", + "location_id": cls.packing_location.id, + } + ) + ) + cls.packing_b_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Packing B", + "barcode": "Packing-B", + "location_id": cls.packing_location.id, + } + ) + ) + + +class ClusterPickingPrepareUnloadCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /prepare_unload endpoint + + Destination packages have been set on all the move lines of the batch. + The unload operation will start, but we have 2 paths for this: + + 1. unload all the destination packages at the same place + 2. unload the destination packages one by one at different places + + By default, if all the move lines have the same destination, the + first path is used. A flag on the batch picking keeps track of which + path is used. + """ + + def test_prepare_unload_all_same_dest(self): + """All move lines have the same destination location""" + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines[:2], self.bin1) + self._set_dest_package_and_done(move_lines[2:], self.bin2) + 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 + data = self._data_for_batch(self.batch, location) + self.assert_response( + response, + next_state="unload_all", + data=data, + ) + + 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[:2], self.bin1) + self._set_dest_package_and_done(move_lines[2:], self.bin2) + move_lines[:2].write({"location_dest_id": self.packing_a_location.id}) + move_lines[2:].write({"location_dest_id": self.packing_b_location.id}) + response = self.service.dispatch( + "prepare_unload", params={"picking_batch_id": self.batch.id} + ) + first_line = move_lines[0] + location = first_line.location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) + self.assert_response( + response, + next_state="unload_single", + data=data, + ) + + +class ClusterPickingSetDestinationAllCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /set_destination_all endpoint + + All the picked lines go to the same destination, a single call to this + endpoint set them as "unloaded" and set the destination. When the last + available line of a picking is unloaded, the picking is set to 'done'. + """ + + def test_set_destination_all_ok(self): + """Set destination on all lines for the full batch and end the process""" + move_lines = self.move_lines + # put destination packages, the whole quantity on lines and a similar + # destination (when /set_destination_all is called, all the lines to + # unload must have the same destination) + self._set_dest_package_and_done(move_lines[:2], self.bin1) + self._set_dest_package_and_done(move_lines[2:], self.bin2) + move_lines.write({"location_dest_id": self.packing_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + # since the whole batch is complete, we expect the batch and all + # pickings to be 'done' + self.assertRecordValues( + move_lines.mapped("picking_id"), [{"state": "done"}, {"state": "done"}] + ) + self.assertRecordValues( + move_lines, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "location_dest_id": self.packing_location.id, + }, + ], + ) + self.assertRecordValues(self.batch, [{"state": "done"}]) + self.assert_response( + response, + next_state="start", + message={"message_type": "success", "body": "Batch Transfer complete"}, + ) + + def test_set_destination_all_remaining_lines(self): + """Set destination on all lines for a part of the batch""" + # Put destination packages, the whole quantity on lines and a similar + # destination (when /set_destination_all is called, all the lines to + # unload must have the same destination). + # However, we keep a line without qty_done and destination package, + # so when the dest location is set, the endpoint should route back + # to the 'start_line' state to work on the remaining line. + lines_to_unload = self.move_lines[:2] + self._set_dest_package_and_done(lines_to_unload, self.bin1) + lines_to_unload.write({"location_dest_id": self.packing_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + # Since the whole batch is not complete, state should not be done. + # The picking with one line should be "done" because we unloaded its line. + # The second one still has a line to pick. + self.assertRecordValues(self.one_line_picking, [{"state": "done"}]) + self.assertRecordValues(self.two_lines_picking, [{"state": "assigned"}]) + self.assertRecordValues( + self.move_lines, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "picking_id": self.one_line_picking.id, + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": True, + "qty_done": 10, + # will be done when the second line of the picking is unloaded + "state": "assigned", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_location.id, + }, + { + "shopfloor_unloaded": False, + "qty_done": 0, + "state": "assigned", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_location.id, + }, + ], + ) + self.assertRecordValues(self.batch, [{"state": "in_progress"}]) + + self.assert_response( + # the remaining move line still needs to be picked + response, + next_state="start_line", + data=self._line_data(self.move_lines[2]), + message={"body": "Batch Transfer line done", "message_type": "success"}, + ) + + def test_set_destination_all_picking_unassigned(self): + """Set destination on lines for some transfers of the batch. + + The remaining transfers stay as unavailable (confirmed) and are removed + from the batch when this one is validated. + The remaining transfers will be processed later in a new batch. + """ + self.batch.picking_ids.do_unreserve() + location = self.one_line_picking.location_id + product = self.one_line_picking.move_ids.product_id + qty = self.one_line_picking.move_ids.product_uom_qty + self._update_qty_in_location(location, product, qty) + self.one_line_picking.action_assign() + # Prepare lines to process + lines = self.one_line_picking.move_line_ids + self._set_dest_package_and_done(lines, self.bin1) + lines.write({"location_dest_id": self.packing_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + # The batch should be done with only one picking. + # The remaining picking has been removed from the current batch + self.assertRecordValues(self.one_line_picking, [{"state": "done"}]) + self.assertRecordValues(self.two_lines_picking, [{"state": "confirmed"}]) + self.assertRecordValues(self.batch, [{"state": "done"}]) + self.assertEqual(self.one_line_picking.batch_id, self.batch) + self.assertFalse(self.two_lines_picking.batch_id) + + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.batch_transfer_complete(), + ) + + def test_set_destination_all_but_different_dest(self): + """Endpoint was called but destinations are different""" + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines[:2].write({"location_dest_id": self.packing_a_location.id}) + move_lines[2:].write({"location_dest_id": self.packing_b_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_location.barcode, + }, + ) + location = move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) + self.assert_response( + response, + next_state="unload_single", + data=data, + ) + + def test_set_destination_all_error_location_not_found(self): + """Endpoint called with a barcode not existing for a location""" + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={"picking_batch_id": self.batch.id, "barcode": "NOTFOUND"}, + ) + 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={ + "message_type": "error", + "body": "No location found for this barcode.", + }, + ) + + def test_set_destination_all_error_location_invalid(self): + """Endpoint called with a barcode for an invalid location + + It is invalid when the location is not the destination location or + sublocation of the picking type. + """ + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.dispatch_location.barcode, + }, + ) + 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={"message_type": "error", "body": "You cannot place it here"}, + ) + + def test_set_destination_all_error_location_move_invalid(self): + """Endpoint called with a barcode for an invalid location + + It is invalid when the location is not a sublocation of the picking + or move destination + """ + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines[0].move_id.location_dest_id = self.packing_a_location + move_lines[0].picking_id.location_dest_id = self.packing_a_location + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_b_location.barcode, + }, + ) + 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.dest_location_not_allowed(), + ) + + def test_set_destination_all_need_confirmation(self): + """Endpoint called with a barcode for another (valid) location""" + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_b_location.barcode, + }, + ) + location = move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location) + self.assert_response( + response, + next_state="confirm_unload_all", + data=data, + ) + + def test_set_destination_all_with_confirmation(self): + """Endpoint called with a barcode for another (valid) location, confirm""" + move_lines = self.move_lines + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_a_location.id}) + + response = self.service.dispatch( + "set_destination_all", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.packing_b_location.barcode, + "confirmation": True, + }, + ) + self.assertRecordValues( + move_lines, + [ + {"location_dest_id": self.packing_b_location.id}, + {"location_dest_id": self.packing_b_location.id}, + {"location_dest_id": self.packing_b_location.id}, + ], + ) + self.assert_response( + response, + next_state="start", + message={"message_type": "success", "body": "Batch Transfer complete"}, + ) + + +class ClusterPickingUnloadSplitCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /unload_split endpoint + + All the destinations of the bins were the same so the "unload all" screen + was presented to the user, but they want different destination, so they hit + the "split" button. From now on, the workflow should use the "unload single" + screen even if the destinations are the same. + """ + + def test_unload_split_ok(self): + """Call /unload_split and continue to unload single""" + move_lines = self.move_lines + # put destination packages, the whole quantity on lines and a similar + # destination (when /set_destination_all is called, all the lines to + # unload must have the same destination) + self._set_dest_package_and_done(move_lines, self.bin1) + move_lines.write({"location_dest_id": self.packing_location.id}) + + response = self.service.dispatch( + "unload_split", params={"picking_batch_id": self.batch.id} + ) + location = move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) + self.assert_response( + # the remaining move line still needs to be picked + response, + next_state="unload_single", + data=data, + ) + + +class ClusterPickingUnloadScanPackCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /unload_scan_pack endpoint + + Goods have been put in the package bins, they have different destinations + or /unload_split has been called, now user has to unload package per + package. For this, they'll first scan the bin package, which will call the + endpoint /unload_scan_pack. (second step will be to set the destination + with /unload_scan_destination, in a different test case) + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls._set_dest_package_and_done(cls.move_lines, cls.bin1) + cls.move_lines[:2].write({"location_dest_id": cls.packing_a_location.id}) + cls.move_lines[2:].write({"location_dest_id": cls.packing_b_location.id}) + + def test_unload_scan_pack_ok(self): + """Endpoint /unload_scan_pack is called, result ok""" + response = self.service.dispatch( + "unload_scan_pack", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.bin1.name, + }, + ) + location = self.move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) + self.assert_response( + response, + next_state="unload_set_destination", + data=data, + ) + + def test_unload_scan_pack_wrong_barcode(self): + """Endpoint /unload_scan_pack is called, wrong barcode scanned""" + response = self.service.dispatch( + "unload_scan_pack", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.bin2.name, + }, + ) + location = self.move_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) + self.assert_response( + response, + next_state="unload_single", + data=data, + message={"message_type": "error", "body": "Wrong bin"}, + ) + + +class ClusterPickingUnloadScanDestinationCase(ClusterPickingUnloadingCommonCase): + """Tests covering the /unload_scan_destination endpoint + + Goods have been put in the package bins, they have different destinations + or /unload_split has been called, now user has to unload package per + package. For this, they'll first scanned the bin package already (endpoint + /unload_scan_pack), now they have to set the destination with + /unload_scan_destination for the scanned pack. + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.bin1_lines = cls.move_lines[:1] + cls.bin2_lines = cls.move_lines[1:] + cls._set_dest_package_and_done(cls.bin1_lines, cls.bin1) + cls._set_dest_package_and_done(cls.bin2_lines, cls.bin2) + cls.bin1_lines.write({"location_dest_id": cls.packing_a_location.id}) + cls.bin2_lines.write({"location_dest_id": cls.packing_b_location.id}) + cls.one_line_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_ids) == 1 + ) + cls.two_lines_picking = cls.batch.picking_ids.filtered( + lambda picking: len(picking.move_ids) == 2 + ) + + def test_unload_scan_destination_ok(self): + """Endpoint /unload_scan_destination is called, result ok""" + dest_location = self.bin1_lines[0].location_dest_id + + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": dest_location.barcode, + }, + ) + + # The scan of destination set 'unloaded' to True to track the fact + # that we set the destination for the line. In this case, the line + # and the stock.picking are 'done' because all the lines of the picking + # have been unloaded + self.assertRecordValues(self.one_line_picking, [{"state": "done"}]) + self.assertRecordValues(self.two_lines_picking, [{"state": "assigned"}]) + self.assertRecordValues( + self.bin1_lines, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "picking_id": self.one_line_picking.id, + "location_dest_id": self.packing_a_location.id, + } + ], + ) + self.assertRecordValues( + self.bin2_lines, + [ + { + "shopfloor_unloaded": False, + "qty_done": 10, + "state": "assigned", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_b_location.id, + }, + { + "shopfloor_unloaded": False, + "qty_done": 10, + "state": "assigned", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_b_location.id, + }, + ], + ) + self.assertRecordValues(self.batch, [{"state": "in_progress"}]) + + location = self.bin2_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin2) + self.assert_response( + response, + next_state="unload_single", + data=data, + ) + + def test_scan_destination_unload_package_enabled(self): + """Endpoint /unload_scan_destination is called, unload_package_at_destination + is enabled, lines.result_package_id should be False""" + dest_location = self.bin1_lines[0].location_dest_id + # Default behavior, result_package_id is kept when package in unloaded + self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": dest_location.barcode, + "confirmation": True, + }, + ) + self.assertRecordValues( + self.bin1_lines, + [ + { + "result_package_id": self.bin1.id, + } + ], + ) + # Now, if `unload_package_at_destination` is enabled, result_package_id + # should be set to False + self.menu.sudo().write({"unload_package_at_destination": True}) + self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin2.id, + "barcode": dest_location.barcode, + "confirmation": True, + }, + ) + self.assertRecordValues( + self.bin2_lines, + [ + { + "result_package_id": False, + }, + { + "result_package_id": False, + }, + ], + ) + + def test_unload_scan_destination_one_line_of_picking_only(self): + """Endpoint /unload_scan_destination is called, only one line of picking""" + # For this test, we assume the move in bin1 is already done. + self.one_line_picking._action_done() + # And for the second picking, we put one line bin2 and one line in bin3 + # so the user would have to go through 2 screens for each pack. + # After scanning and setting the destination for bin2, the picking will + # still be "assigned" and they'll have to scan bin3 (but this test stops + # at bin2) + bin3 = self.env["stock.quant.package"].create({}) + bin2_line = self.bin2_lines[0] + bin3_line = self.bin2_lines[1] + self._set_dest_package_and_done(bin3_line, bin3) + + dest_location = bin2_line.location_dest_id + + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin2.id, + "barcode": dest_location.barcode, + }, + ) + + # The scan of destination set 'unloaded' to True to track the fact + # that we set the destination for the line. The picking is still + # assigned because the second line still has to be unloaded. + self.assertRecordValues(self.two_lines_picking, [{"state": "assigned"}]) + self.assertRecordValues( + bin2_line, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "assigned", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_b_location.id, + } + ], + ) + self.assertRecordValues( + bin3_line, + [ + { + "shopfloor_unloaded": False, + "qty_done": 10, + "state": "assigned", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_b_location.id, + } + ], + ) + self.assertRecordValues(self.batch, [{"state": "in_progress"}]) + location = bin3_line.location_dest_id + data = self._data_for_batch(self.batch, location, pack=bin3) + self.assert_response( + response, + next_state="unload_single", + data=data, + ) + + def test_unload_scan_destination_last_line(self): + """Endpoint /unload_scan_destination is called on last line""" + # For this test, we assume the move in bin1 is already done. + self.one_line_picking._action_done() + # And for the second picking, bin2 was already unloaded, + # remains bin3 to unload. + bin3 = self.env["stock.quant.package"].create({}) + bin2_line = self.bin2_lines[0] + bin3_line = self.bin2_lines[1] + self._set_dest_package_and_done(bin3_line, bin3) + bin2_line.shopfloor_unloaded = True + + dest_location = bin3_line.location_dest_id + + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": bin3.id, + "barcode": dest_location.barcode, + }, + ) + + # The scan of destination set 'unloaded' to True to track the fact + # that we set the destination for the line. The picking is done + # because all the lines have been unloaded + self.assertRecordValues(self.two_lines_picking, [{"state": "done"}]) + self.assertRecordValues( + bin3_line, + [ + { + "shopfloor_unloaded": True, + "qty_done": 10, + "state": "done", + "picking_id": self.two_lines_picking.id, + "location_dest_id": self.packing_b_location.id, + } + ], + ) + self.assertRecordValues(self.batch, [{"state": "done"}]) + + self.assert_response( + response, + next_state="start", + message={"body": "Batch Transfer complete", "message_type": "success"}, + ) + + def test_unload_scan_destination_error_location_not_found(self): + """Endpoint called with a barcode not existing for a location""" + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": "¤", + }, + ) + location = self.bin1_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) + self.assert_response( + response, + next_state="unload_set_destination", + data=data, + message={ + "message_type": "error", + "body": "No location found for this barcode.", + }, + ) + + def test_unload_scan_destination_error_location_invalid(self): + """Endpoint called with a barcode for an invalid location + + It is invalid when the location is not the destination location or + sublocation of the picking type. + """ + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.dispatch_location.barcode, + }, + ) + location = self.bin1_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) + self.assert_response( + response, + next_state="unload_set_destination", + data=data, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + + def test_unload_scan_destination_error_location_move_invalid(self): + """Endpoint called with a barcode for an invalid location + + It is invalid when the location is not a sublocation of the picking + or move destination + """ + self.bin1_lines[0].picking_id.location_dest_id = self.packing_a_location + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.packing_b_location.barcode, + }, + ) + location = self.bin1_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) + self.assert_response( + response, + next_state="unload_set_destination", + data=data, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_unload_scan_destination_need_confirmation(self): + """Endpoint called with a barcode for another (valid) location""" + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": self.packing_b_location.barcode, + }, + ) + location = self.bin1_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin1) + self.assert_response( + response, + next_state="confirm_unload_set_destination", + data=data, + ) + + def test_unload_scan_destination_with_confirmation(self): + """Endpoint called with a barcode for another (valid) location, confirm""" + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin2.id, + "barcode": self.packing_a_location.barcode, + "confirmation": True, + }, + ) + self.assertRecordValues( + self.bin2.quant_ids, + [ + {"location_id": self.packing_a_location.id}, + {"location_id": self.packing_a_location.id}, + ], + ) + self.assertRecordValues( + self.two_lines_picking.move_line_ids, + [ + {"location_dest_id": self.packing_a_location.id}, + {"location_dest_id": self.packing_a_location.id}, + ], + ) + self.assert_response(response, next_state="unload_single", data=self.ANY) + + def test_unload_scan_destination_completion_info(self): + """/unload_scan_destination that make chained picking ready""" + picking = self.one_line_picking + dest_location = picking.move_line_ids.location_dest_id + self.picking_type.sudo().display_completion_info = True + + # create a chained picking after the current one + next_picking = picking.copy( + { + "picking_type_id": self.wh.out_type_id.id, + "location_id": dest_location.id, + "location_dest_id": self.customer_location.id, + } + ) + next_picking.move_ids.write( + { + "move_orig_ids": [(6, 0, picking.move_ids.ids)], + "location_id": dest_location.id, + } + ) + next_picking.action_confirm() + + response = self.service.dispatch( + "unload_scan_destination", + params={ + "picking_batch_id": self.batch.id, + "package_id": self.bin1.id, + "barcode": dest_location.barcode, + }, + ) + location = self.bin2_lines[0].location_dest_id + data = self._data_for_batch(self.batch, location, pack=self.bin2) + self.assert_response( + response, + next_state="unload_single", + popup={ + "body": "Last operation of transfer {}. Next operation " + "({}) is ready to proceed.".format(picking.name, next_picking.name) + }, + data=data, + ) diff --git a/shopfloor/tests/test_delivery_base.py b/shopfloor/tests/test_delivery_base.py new file mode 100644 index 0000000000..26634b52e9 --- /dev/null +++ b/shopfloor/tests/test_delivery_base.py @@ -0,0 +1,155 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .common import CommonCase + +# pylint: disable=missing-return + + +class DeliveryCommonCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_demo_delivery") + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.wh.sudo().delivery_steps = "pick_pack_ship" + cls.product_e = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product E", + "type": "product", + "default_code": "E", + "barcode": "E", + "weight": 3, + } + ) + ) + cls.product_e_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_e.id, + "barcode": "ProductEBox", + } + ) + ) + cls.product_f = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product F", + "type": "product", + "default_code": "F", + "barcode": "F", + "weight": 3, + } + ) + ) + cls.product_f_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_f.id, + "barcode": "ProductFBox", + } + ) + ) + cls.product_g = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product G", + "type": "product", + "default_code": "G", + "barcode": "G", + "weight": 1, + } + ) + ) + cls.product_g_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_g.id, + "barcode": "ProductGBox", + } + ) + ) + + def setUp(self): + super().setUp() + self.service = self.get_service( + "delivery", menu=self.menu, profile=self.profile + ) + + def _stock_picking_data(self, picking): + return self.service.data_detail.picking_detail(picking) + + def _stock_location_data(self, location): + return self.service.data.location(location, with_operation_progress=True) + + def assert_response_deliver( + self, response, picking=None, message=None, location=None + ): + self.assert_response( + response, + next_state="deliver", + data={ + "picking": self._stock_picking_data(picking) if picking else None, + "sublocation": self._stock_location_data(location) + if location + else None, + }, + message=message, + ) + + def assert_response_manual_selection(self, response, pickings=None, message=None): + self.assert_response( + response, + next_state="manual_selection", + data={ + "pickings": [self._stock_picking_data(picking) for picking in pickings] + }, + message=message, + ) + + def assert_qty_done(self, move_lines, qties=None): + """Ensure that the quantities done are the expected ones. + + If `qties` is not defined, the expected qties are `product_uom_qty` + of the move lines. + `qties` parameter is a list of move lines qty (same order). + """ + if qties: + assert len(move_lines) == len(qties), "'qties' doesn't match 'move_lines'" + expected_qties = [] + for qty in qties: + expected_qties.append({"qty_done": qty}) + else: + expected_qties = [ + {"qty_done": line.reserved_uom_qty} for line in move_lines + ] + self.assertRecordValues(move_lines, expected_qties) + package_level = move_lines.package_level_id + if package_level: + values = [{"is_done": True}] + if qties: + values = [{"is_done": bool(qty)} for qty in qties] + # we have a package level only when there is a package + self.assertRecordValues(package_level, values) diff --git a/shopfloor/tests/test_delivery_done.py b/shopfloor/tests/test_delivery_done.py new file mode 100644 index 0000000000..263ed88af5 --- /dev/null +++ b/shopfloor/tests/test_delivery_done.py @@ -0,0 +1,108 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + +# pylint: disable=missing-return + + +class DeliveryDoneCase(DeliveryCommonCase): + """Tests for /done""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C as raw product + (cls.product_c, 10), + ] + ) + cls.pack1_moves = picking.move_ids[:2] + cls.raw_move = cls.picking.move_ids[2] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.raw_move) + cls.picking.action_assign() + + def assert_response_confirm_done(self, response, picking=None, message=None): + self.assert_response( + response, + next_state="confirm_done", + data={"picking": self._stock_picking_data(picking) if picking else None}, + message=message, + ) + + def test_done_picking_not_found(self): + response = self.service.dispatch("done", params={"picking_id": -1}) + self.assert_response_deliver( + response, message=self.service.msg_store.stock_picking_not_found() + ) + + def test_done_all_qty_done(self): + # Do not use the /set_qty_done_line endpoint to set done qties to not + # update the picking to 'done' state automatically + for move_line in self.picking.move_line_ids: + move_line.qty_done = move_line.reserved_uom_qty + response = self.service.dispatch("done", params={"picking_id": self.picking.id}) + self.assert_response_deliver( + response, + message=self.service.msg_store.transfer_complete(self.picking), + ) + self.assertEqual(self.picking.state, "done") + + def test_done_no_qty_done(self): + response = self.service.dispatch("done", params={"picking_id": self.picking.id}) + self.assert_response_confirm_done( + response, + picking=self.picking, + message=self.service.msg_store.transfer_confirm_done(), + ) + self.assertEqual(self.picking.state, "assigned") + + def test_done_some_qty_done(self): + move_line = self.raw_move.move_line_ids[0] + self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + response = self.service.dispatch("done", params={"picking_id": self.picking.id}) + self.assert_response_confirm_done( + response, + picking=self.picking, + message=self.service.msg_store.transfer_confirm_done(), + ) + self.assertEqual(self.picking.state, "assigned") + + def test_done_no_qty_done_confirm(self): + self.assertEqual(self.picking.state, "assigned") + response = self.service.dispatch( + "done", params={"picking_id": self.picking.id, "confirm": True} + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.transfer_no_qty_done(), + ) + self.assertEqual(self.picking.state, "assigned") + + def test_done_some_qty_done_confirm(self): + move_line = self.raw_move.move_line_ids[0] + self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + response = self.service.dispatch( + "done", params={"picking_id": self.picking.id, "confirm": True} + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.transfer_complete(self.picking), + ) + self.assertEqual(self.picking.state, "done") + self.assertEqual(self.picking.move_ids, self.raw_move) + backorder = self.picking.backorder_ids + self.assertTrue(backorder) + self.assertEqual(self.pack1_moves.picking_id, backorder) diff --git a/shopfloor/tests/test_delivery_list_stock_picking.py b/shopfloor/tests/test_delivery_list_stock_picking.py new file mode 100644 index 0000000000..ca76f5a9d0 --- /dev/null +++ b/shopfloor/tests/test_delivery_list_stock_picking.py @@ -0,0 +1,49 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + +# pylint: disable=missing-return + + +class DeliveryListStockPickingCase(DeliveryCommonCase): + """Tests for /list_stock_picking""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + + def test_list_stock_picking_ko(self): + """No picking is ready, no picking to list.""" + response = self.service.dispatch("list_stock_picking", params={}) + self.assert_response_manual_selection( + response, + pickings=[], + ) + + def test_list_stock_picking_ok(self): + """Picking ready to list.""" + # prepare 1st picking + self._fill_stock_for_moves(self.picking1.move_ids) + self.picking1.action_assign() + response = self.service.dispatch("list_stock_picking", params={}) + # picking1 only available + self.assert_response_manual_selection( + response, + pickings=self.picking1, + ) + # prepare 2nd picking + self._fill_stock_for_moves(self.picking2.move_ids) + self.picking2.action_assign() + response = self.service.dispatch("list_stock_picking", params={}) + # all pickings available + self.assert_response_manual_selection( + response, + pickings=self.picking1 + self.picking2, + ) diff --git a/shopfloor/tests/test_delivery_reset_qty_done_line.py b/shopfloor/tests/test_delivery_reset_qty_done_line.py new file mode 100644 index 0000000000..10671564cc --- /dev/null +++ b/shopfloor/tests/test_delivery_reset_qty_done_line.py @@ -0,0 +1,119 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + +# pylint: disable=missing-return + + +class DeliveryResetQtyDoneLineCase(DeliveryCommonCase): + """Tests for /reset_qty_done_line""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C as raw product + (cls.product_c, 10), + ] + ) + cls.pack1_moves = picking.move_ids[:2] + cls.raw_move = picking.move_ids[2] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.raw_move) + picking.action_assign() + # Some records not related at all to the processed picking + cls.free_picking = cls._create_picking(lines=[(cls.product_d, 10)]) + cls.free_raw_move = cls.free_picking.move_ids[0] + cls._fill_stock_for_moves(cls.free_raw_move) + cls.free_picking.action_assign() + + def test_reset_qty_done_line_picking_not_found(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + response = self.service.dispatch( + "reset_qty_done_line", + params={"move_line_id": move_lines[0].id, "picking_id": -1}, + ) + self.assert_response_deliver( + response, message=self.service.msg_store.stock_picking_not_found() + ) + + def test_reset_qty_done_line_line_not_found(self): + response = self.service.dispatch( + "reset_qty_done_line", + params={"move_line_id": -1, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.record_not_found(), + ) + + def test_reset_qty_done_line_line_not_available_in_picking(self): + move_line = self.free_raw_move.mapped("move_line_ids") + response = self.service.dispatch( + "reset_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.line_not_available_in_picking(self.picking), + ) + + def test_reset_qty_done_line_ok(self): + move_line = self.raw_move.mapped("move_line_ids") + # Set qty done on a line + self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assertTrue(move_line.qty_done == move_line.reserved_uom_qty) + # Reset it, no related move lines are "done" + response = self.service.dispatch( + "reset_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver(response, picking=self.picking) + self.assertFalse(move_line.qty_done) + + def test_reset_qty_done_line_with_package(self): + move_line = self.pack1_moves[0].mapped("move_line_ids") + response = self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.line_has_package_scan_package(), + ) + + def test_reset_qty_done_pack_picking_status(self): + move_lines = self.picking.move_line_ids + raw_move_line = self.raw_move.mapped("move_line_ids") + # Set qty done for all lines (some are linked to packages here), + # picking is automatically set to done + for package in move_lines.mapped("package_id"): + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": raw_move_line.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "done") + # Try to reset one of them => picking already processed + response = self.service.dispatch( + "reset_qty_done_line", + params={"move_line_id": raw_move_line.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, message=self.service.msg_store.already_done() + ) + self.assertEqual(self.picking.state, "done") diff --git a/shopfloor/tests/test_delivery_reset_qty_done_pack.py b/shopfloor/tests/test_delivery_reset_qty_done_pack.py new file mode 100644 index 0000000000..2d771ca034 --- /dev/null +++ b/shopfloor/tests/test_delivery_reset_qty_done_pack.py @@ -0,0 +1,107 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + +# pylint: disable=missing-return + + +class DeliveryResetQtyDonePackCase(DeliveryCommonCase): + """Tests for /reset_qty_done_pack""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C alone in a package + (cls.product_c, 10), + ] + ) + cls.pack1_moves = picking.move_ids[:2] + cls.pack2_move = picking.move_ids[2] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.pack2_move, in_package=True) + picking.action_assign() + # Some records not related at all to the processed picking + cls.free_package = cls.env["stock.quant.package"].create( + {"name": "FREE_PACKAGE"} + ) + + def test_reset_qty_done_pack_picking_not_found(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + response = self.service.dispatch( + "reset_qty_done_pack", params={"package_id": package.id, "picking_id": -1} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.stock_picking_not_found() + ) + + def test_reset_qty_done_pack_package_not_found(self): + response = self.service.dispatch( + "reset_qty_done_pack", + params={"package_id": -1, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.package_not_found(), + ) + + def test_reset_qty_done_pack_package_not_available_in_picking(self): + response = self.service.dispatch( + "reset_qty_done_pack", + params={"package_id": self.free_package.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.package_not_available_in_picking( + self.free_package, self.picking + ), + ) + + def test_reset_qty_done_pack_ok(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + # Set qty done on a package, related move lines are "done" + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assertTrue(all(ml.qty_done == ml.reserved_uom_qty for ml in move_lines)) + # Reset it, no related move lines are "done" + response = self.service.dispatch( + "reset_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver(response, picking=self.picking) + self.assertFalse(any(ml.qty_done > 0 for ml in move_lines)) + + def test_reset_qty_done_pack_picking_status(self): + package1 = self.pack1_moves.mapped("move_line_ids").mapped("package_id") + package2 = self.pack2_move.mapped("move_line_ids").mapped("package_id") + # Set qty done for all lines (all linked to packages here), picking is + # automatically set to done + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package1.id, "picking_id": self.picking.id}, + ) + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package2.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "done") + # Try to reset one of them => picking already processed + response = self.service.dispatch( + "reset_qty_done_pack", + params={"package_id": package1.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, message=self.service.msg_store.already_done() + ) + self.assertEqual(self.picking.state, "done") diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py new file mode 100644 index 0000000000..32c7976065 --- /dev/null +++ b/shopfloor/tests/test_delivery_scan_deliver.py @@ -0,0 +1,557 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_delivery_base import DeliveryCommonCase + +# pylint: disable=missing-return + + +class DeliveryScanDeliverCase(DeliveryCommonCase): + """Tests for /scan_deliver""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_e.tracking = "lot" + cls.picking = picking = cls._create_picking( + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C alone in a package + (cls.product_c, 10), + # D as raw product + (cls.product_d, 10), + # E as raw product with a lot + (cls.product_e, 10), + # F in two different packages + (cls.product_f, 10), + # G in a package with quantity of one. + (cls.product_g, 10), + ] + ) + cls.pack1_moves = picking.move_ids[:2] + cls.pack2_move = picking.move_ids[2] + cls.pack3_move = picking.move_ids[5] + cls.pack4_move = picking.move_ids[6] + cls.raw_move = picking.move_ids[3] + cls.raw_lot_move = picking.move_ids[4] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.pack2_move, in_package=True) + cls._fill_stock_for_moves(cls.pack4_move, in_package=True) + cls._fill_stock_for_moves(cls.raw_move) + cls._fill_stock_for_moves(cls.raw_lot_move, in_lot=True) + # Set a lot for A for the mixed package (A + B) + cls.product_a_lot = cls.env["stock.lot"].create( + {"product_id": cls.product_a.id, "company_id": cls.env.company.id} + ) + cls.product_a_quant = cls.env["stock.quant"].search( + [("product_id", "=", cls.product_a.id)] + ) + cls.product_a_quant.sudo().lot_id = cls.product_a_lot + # Fill stock for F moves (two packages) + for __ in range(2): + product_f_pkg = cls.env["stock.quant.package"].create({}) + cls._update_qty_in_location( + cls.pack3_move.location_id, + cls.pack3_move.product_id, + 5, + package=product_f_pkg, + ) + picking.action_assign() + # Add a packaging on the raw product + cls.packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "TEST PACKAGING", + "product_id": cls.raw_move.product_id.id, + "qty": 10, + "product_uom_id": cls.raw_move.product_id.uom_id.id, + "barcode": "TEST_PACKAGING", + } + ) + ) + # Some records not related at all to the processed picking + cls.free_package = cls.env["stock.quant.package"].create( + {"name": "FREE_PACKAGE"} + ) + cls.free_lot = cls.env["stock.lot"].create( + { + "name": "FREE_LOT", + "product_id": cls.product_a.id, + "company_id": cls.env.company.id, + } + ) + cls.free_product = ( + cls.env["product.product"] + .sudo() + .create({"name": "FREE_PRODUCT", "barcode": "FREE_PRODUCT"}) + ) + + def test_scan_deliver_scan_picking_ok(self): + response = self.service.dispatch( + "scan_deliver", + params={ + "barcode": self.picking.name, + "picking_id": None, + "location_id": None, + }, + ) + self.assert_response_deliver(response, picking=self.picking) + + def test_scan_deliver_error_barcode_not_found(self): + response = self.service.dispatch( + "scan_deliver", params={"barcode": "NO VALID BARCODE", "picking_id": None} + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.barcode_not_found(), + ) + + def test_scan_deliver_error_barcode_not_found_keep_picking(self): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": "NO VALID BARCODE", "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + # if the client was working on a picking (it sends picking_id, then + # send refreshed data) + picking=self.picking, + message=self.service.msg_store.barcode_not_found(), + ) + + def _test_scan_set_done_ok(self, move_lines, barcode, qties=None): + response = self.service.dispatch("scan_deliver", params={"barcode": barcode}) + self.assert_qty_done(move_lines, qties) + picking = move_lines.move_id.picking_id + if picking.state == "done": + self.assert_response_deliver( + response, message=self.msg_store.transfer_complete(picking) + ) + else: + self.assert_response_deliver(response, picking=picking) + + def test_scan_deliver_scan_package(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + self.assertEqual(self.picking.state, "assigned") + self._test_scan_set_done_ok(move_lines, package.name) + self.assertEqual(self.picking.state, "assigned") + + def test_scan_deliver_scan_package_with_prepackaged_product(self): + """Check scanning a package process it entirely. + + "Process as pre-packaged product" option is enabled to create a backorder. + """ + self.menu.sudo().allow_prepackaged_product = True + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + self.assertEqual(self.picking.state, "assigned") + response = self.service.dispatch( + "scan_deliver", params={"barcode": package.name} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.transfer_complete(self.picking) + ) + for line in move_lines: + self.assertEqual(line.move_id.product_uom_qty, line.move_id.quantity_done) + self.assertEqual(line.move_id.state, "done") + self.assertEqual(self.picking.state, "done") + self.assertTrue(self.picking.backorder_ids) + + def test_scan_deliver_scan_package_no_move_lines(self): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": self.free_package.name, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.cannot_move_something_in_picking_type(), + ) + + def test_scan_deliver_scan_product_not_in_package(self): + """Check scanning product increment quantity done by one.""" + for qty_done in range(1, 3): + response = self.service.dispatch( + "scan_deliver", + params={ + "barcode": self.product_d.barcode, + "picking_id": self.picking.id, + }, + ) + self.assertEqual(self.raw_move.move_line_ids.qty_done, qty_done) + + self.assert_response_deliver( + response, + picking=self.picking, + ) + + def test_scan_deliver_scan_product_in_package_multiple(self): + """Check product scanned alone in a package but quantity more than one.""" + response = self.service.dispatch( + "scan_deliver", + params={"barcode": self.product_c.barcode, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.product_not_unitary_in_package_scan_package(), + ) + + def test_scan_deliver_scan_product_in_multiple_packages(self): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": self.product_f.barcode, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.product_multiple_packages_scan_package(), + ) + + def test_scan_deliver_scan_product_in_mixed_package(self): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": self.product_a.barcode, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.product_mixed_package_scan_package(), + ) + + def test_scan_deliver_scan_product_tracked_by_lot(self): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": self.product_e.barcode, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + + def test_scan_deliver_scan_raw_product_ok(self): + self._test_scan_set_done_ok( + self.raw_move.mapped("move_line_ids"), + self.product_d.barcode, + [1], # When scanning a product we want to process only 1 qty + ) + + def test_scan_deliver_scan_raw_product_in_multiple_pickings(self): + # Scan a raw product (not related to a package or lot) which is present + # in multiple delivery operations (so two different moves). + # We should be able to process these two moves one after the other. + self.picking.do_unreserve() + self.raw_move.product_uom_qty = 1 + self.picking.action_assign() + picking2 = self._create_picking( + lines=[ + # D as raw product + (self.product_d, 1), + ] + ) + raw_move2 = picking2.move_ids + self._fill_stock_for_moves(raw_move2) + picking2.action_assign() + # Scan the first move + self._test_scan_set_done_ok( + self.raw_move.mapped("move_line_ids"), self.product_d.barcode + ) + # Scan the second move + # NOTE: we do not use '_test_scan_set_done_ok' here as we expect + # the delivery to be complete (we process its only move line). + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.product_d.barcode} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.transfer_complete(self.picking) + ) + self.assertEqual(raw_move2.quantity_done, 1) + self.assertEqual(raw_move2.state, "done") + + def test_scan_deliver_scan_product_not_found(self): + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.free_product.barcode} + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.product_not_found_in_pickings(), + ) + + def test_scan_deliver_scan_lot(self): + """Check scanning a lot process only one piece/unit of this lot.""" + line = self.raw_lot_move.move_line_ids + lot = line.lot_id + response = self.service.dispatch("scan_deliver", params={"barcode": lot.name}) + self.assert_response_deliver( + response, + picking=self.picking, + ) + self.assertEqual(line.qty_done, 1) + self.assertEqual(line.state, "assigned") + for _ in range(int(line.reserved_uom_qty) - 1): + self.service.dispatch( + "scan_deliver", + params={ + "barcode": lot.name, + "picking_id": self.picking.id, + }, + ) + self.assertEqual(line.qty_done, self.raw_lot_move.product_uom_qty) + + def test_scan_deliver_scan_lot_with_prepackaged_product(self): + """Check scanning a lot process only one piece/unit of this lot. + + "Process as pre-packaged product" option is enabled to create a backorder. + """ + self.menu.sudo().allow_prepackaged_product = True + line = self.raw_lot_move.move_line_ids + lot = line.lot_id + response = self.service.dispatch("scan_deliver", params={"barcode": lot.name}) + self.assert_response_deliver( + response, message=self.service.msg_store.transfer_complete(self.picking) + ) + self.assertEqual(line.qty_done, 1) + self.assertEqual(line.move_id.state, "done") + self.assertEqual(self.picking.state, "done") + self.assertTrue(self.picking.backorder_ids) + + def test_scan_deliver_scan_lot_not_found(self): + response = self.service.dispatch("scan_deliver", params={"barcode": "FREE_LOT"}) + self.assert_response_deliver( + response, + message=self.service.msg_store.lot_not_found_in_pickings(), + ) + + def test_scan_deliver_scan_lot_in_mixed_package(self): + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.product_a_lot.name} + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.lot_mixed_package_scan_package(), + ) + + def test_scan_deliver_scan_product_packaging(self): + """Check scanning a product packaging use the packaging quantity. + + Quantity on the line is the packaging quantity + """ + # Scan a product packaging having the same qty than the qty to ship. + # We have 10 qties to ship and we scan a product packaging of 10 qties. + line = self.raw_move.mapped("move_line_ids") + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.packaging.barcode} + ) + self.assert_response_deliver(response, picking=self.picking) + self.assertEqual(line.qty_done, self.packaging.qty) + + def test_scan_deliver_scan_product_packaging_with_prepackaged_product(self): + """Check scanning a product packaging use the packaging quantity. + + Quantity on the line is the packaging quantity + + "Process as pre-packaged product" option is enabled to create a backorder. + """ + # Scan a product packaging having the same qty than the qty to ship. + # We have 10 qties to ship and we scan a product packaging of 10 qties. + self.menu.sudo().allow_prepackaged_product = True + line = self.raw_move.mapped("move_line_ids") + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.packaging.barcode} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.transfer_complete(self.picking) + ) + self.assertEqual(line.qty_done, self.packaging.qty) + + def test_scan_deliver_scan_product_packaging_partial_qty(self): + # Scan a product packaging with a smaller qty than the move line + # We have 10 qties to ship but we scan a product packaging of 5 qties. + # -> Processed 5 over 10 qties + # Then we scan a second time the product packaging all qties will be processed + # -> Processed 10/10 + self.packaging.qty = 5 + line = self.raw_move.mapped("move_line_ids") + self.assertEqual(line.move_id.product_qty, 10) + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.packaging.barcode} + ) + self.assert_response_deliver(response, picking=self.picking) + self.assertEqual(line.qty_done, self.packaging.qty) + self.assertTrue(line.move_id.product_qty > self.packaging.qty) + # Process the remaining qties, still by scanning the packaging + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.packaging.barcode} + ) + self.assert_response_deliver(response, picking=self.picking) + self.assertEqual(line.move_id.product_qty, line.move_id.quantity_done) + self.assertEqual(line.move_id.state, "assigned") + + def test_scan_deliver_scan_product_packaging_partial_qty_with_prepackaged_product( + self, + ): + # Scan a product packaging with a smaller qty than the move line + # while the "Process pre-packaged product" option is enabled. + # We have 10 qties to ship but we scan a product packaging of 5 qties. + # -> Ship 5 (creating a backorder for the 5 remaining) + # Then we scan a second time the product packaging to process the backorder + # -> Ship 5 (again) + self.menu.sudo().allow_prepackaged_product = True + self.packaging.qty = 5 + line = self.raw_move.mapped("move_line_ids") + self.assertEqual(line.move_id.product_qty, 10) + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.packaging.barcode} + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.transfer_complete(self.picking), + ) + self.assertEqual(line.qty_done, self.packaging.qty) + self.assertEqual(line.move_id.product_qty, self.packaging.qty) + self.assertEqual(line.move_id.state, "done") + self.assertTrue(self.picking.backorder_ids) + # Process the backorder + backorder = self.picking.backorder_ids + backorder_raw_move = backorder.move_ids.filtered_domain( + [("product_id", "=", self.product_d.id)] + ) + backorder_line = backorder_raw_move.move_line_ids + response = self.service.dispatch( + "scan_deliver", params={"barcode": self.packaging.barcode} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.transfer_complete(backorder) + ) + self.assertEqual(backorder_line.move_id.product_qty, self.packaging.qty) + self.assertEqual(backorder_line.move_id.state, "done") + + def test_scan_deliver_scan_product_alone_in_package_qty_one(self): + """Check scanning a product alone in a package with a quantity of one.""" + self.picking.action_cancel() + pick = self._create_picking( + lines=[ + (self.product_c, 1), + ] + ) + pack_move = pick.move_ids[:1] + self._fill_stock_for_moves(pack_move, in_package=True) + pick.action_assign() + move_lines = pick.move_ids.mapped("move_line_ids") + self._test_scan_set_done_ok(move_lines, self.product_c.barcode, [1]) + + def test_scan_deliver_picking_done(self): + # Set qty done for all lines (packages/raw product/lot...), picking is + # automatically set to done when the last line is completed + package1 = self.pack1_moves.mapped("move_line_ids").mapped("package_id") + package2 = self.pack2_move.mapped("move_line_ids").mapped("package_id") + package4 = self.pack4_move.mapped("move_line_ids").mapped("package_id") + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package1.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package2.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package4.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + + # When a product is scanned, we process only one unit of it + for _ in range(int(self.raw_move.product_uom_qty)): + self.service.dispatch( + "scan_deliver", + params={ + "barcode": self.raw_move.product_id.barcode, + "picking_id": self.picking.id, + }, + ) + self.assertEqual(self.picking.state, "assigned") + + # When a lot is scanned, we process only one unit of it + lot = self.raw_lot_move.move_line_ids.lot_id + for _ in range(int(self.raw_lot_move.product_uom_qty)): + response = self.service.dispatch( + "scan_deliver", + params={"barcode": lot.name, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + packages_f = self.pack3_move.move_line_ids.mapped("package_id") + # While all lines are not processed, response still returns the picking + self.assert_response_deliver( + response, + picking=self.picking, + ) + response = None + # Once all lines are processed, the last response has no picking returned + for package in packages_f: + response = self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "done") + self.assert_response_deliver( + response, + message=self.service.msg_store.transfer_complete(self.picking), + ) + + +class DeliveryScanDeliverSpecialCase(DeliveryCommonCase): + """Special cases with different setup for /scan_deliver""" + + def test_scan_deliver_error_picking_wrong_type(self): + picking = self._create_picking( + picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)] + ) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + response = self.service.dispatch( + "scan_deliver", params={"barcode": picking.name} + ) + self.assert_response_deliver( + response, + message={ + "message_type": "error", + "body": "You cannot move this using this menu.", + }, + ) + + def test_scan_deliver_error_picking_unavailable(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + response = self.service.dispatch( + "scan_deliver", params={"barcode": picking.name} + ) + self.assert_response_deliver( + response, + message={ + "message_type": "error", + "body": "Transfer {} is not available.".format(picking.name), + }, + ) + + def test_scan_deliver_error_picking_already_done(self): + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_ids, in_package=True) + picking.action_assign() + picking.move_line_ids.qty_done = picking.move_line_ids.reserved_uom_qty + picking._action_done() + response = self.service.dispatch( + "scan_deliver", params={"barcode": picking.name} + ) + self.assert_response_deliver( + response, + message={"message_type": "info", "body": "Operation already processed."}, + ) diff --git a/shopfloor/tests/test_delivery_select.py b/shopfloor/tests/test_delivery_select.py new file mode 100644 index 0000000000..15b240e6fd --- /dev/null +++ b/shopfloor/tests/test_delivery_select.py @@ -0,0 +1,38 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + +# pylint: disable=missing-return + + +class DeliverySelectCase(DeliveryCommonCase): + """Tests for /select""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls._fill_stock_for_moves(cls.picking1.move_ids) + cls._fill_stock_for_moves(cls.picking2.move_ids) + cls.pickings = cls.picking1 | cls.picking2 + cls.pickings.action_assign() + + def test_select_ok(self): + response = self.service.dispatch( + "select", params={"picking_id": self.picking1.id} + ) + self.assert_response_deliver(response, picking=self.picking1) + + def test_select_not_found(self): + response = self.service.dispatch("select", params={"picking_id": -1}) + self.assert_response_manual_selection( + response, + pickings=self.pickings, + message=self.service.msg_store.stock_picking_not_found(), + ) diff --git a/shopfloor/tests/test_delivery_set_qty_done_line.py b/shopfloor/tests/test_delivery_set_qty_done_line.py new file mode 100644 index 0000000000..56a38e1cf0 --- /dev/null +++ b/shopfloor/tests/test_delivery_set_qty_done_line.py @@ -0,0 +1,91 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + +# pylint: disable=missing-return + + +class DeliverySetQtyDoneLineCase(DeliveryCommonCase): + """Tests for /set_qty_done_line""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + # put A in a package + (cls.product_a, 10), + # B as raw product + (cls.product_b, 10), + ] + ) + cls.pack1_move = picking.move_ids[0] + cls.raw_move = picking.move_ids[1] + cls._fill_stock_for_moves(cls.pack1_move, in_package=True) + cls._fill_stock_for_moves(cls.raw_move) + picking.action_assign() + + def _test_set_qty_done_line_ok(self, move_line): + response = self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assert_qty_done(move_line) + self.assert_response_deliver(response, picking=self.picking) + + def test_set_qty_done_line_picking_not_found(self): + move_line = self.pack1_move.mapped("move_line_ids") + response = self.service.dispatch( + "set_qty_done_line", params={"move_line_id": move_line.id, "picking_id": -1} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.stock_picking_not_found() + ) + + def test_set_qty_done_line_picking_canceled(self): + move_line = self.pack1_move.mapped("move_line_ids") + self.picking.action_cancel() + response = self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.stock_picking_not_available(self.picking), + ) + + def test_set_qty_done_line_line_not_found(self): + response = self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": -1, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.record_not_found(), + ) + + def test_set_qty_done_line_ok(self): + move_line = self.raw_move.mapped("move_line_ids") + self._test_set_qty_done_line_ok(move_line) + # picking is still assigned as only one move line have been processed + self.assertEqual(self.picking.state, "assigned") + + def test_set_qty_done_line_picking_done(self): + # process the first move line with a package + move_line = self.pack1_move.mapped("move_line_ids") + package = move_line.mapped("package_id") + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + # process the remaining move line + move_line = self.raw_move.mapped("move_line_ids") + self.service.dispatch( + "set_qty_done_line", + params={"move_line_id": move_line.id, "picking_id": self.picking.id}, + ) + # picking is done once all its moves have been processed + self.assertEqual(self.picking.state, "done") diff --git a/shopfloor/tests/test_delivery_set_qty_done_pack.py b/shopfloor/tests/test_delivery_set_qty_done_pack.py new file mode 100644 index 0000000000..bd7df5ad3f --- /dev/null +++ b/shopfloor/tests/test_delivery_set_qty_done_pack.py @@ -0,0 +1,135 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_delivery_base import DeliveryCommonCase + +# pylint: disable=missing-return + + +class DeliverySetQtyDonePackCase(DeliveryCommonCase): + """Tests for /set_qty_done_pack""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = picking = cls._create_picking( + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C alone in a package + (cls.product_c, 10), + # D in two different packages + (cls.product_d, 10), + ] + ) + cls.pack1_moves = picking.move_ids[:2] + cls.pack2_move = picking.move_ids[2] + cls.pack3_move = picking.move_ids[3] + cls._fill_stock_for_moves(cls.pack1_moves, in_package=True) + cls._fill_stock_for_moves(cls.pack2_move, in_package=True) + # Fill stock for D moves (two packages) + for __ in range(2): + product_d_pkg = cls.env["stock.quant.package"].create({}) + cls._update_qty_in_location( + cls.pack3_move.location_id, + cls.pack3_move.product_id, + 5, + package=product_d_pkg, + ) + picking.action_assign() + + def _test_set_qty_done_pack_ok(self, move_lines, package, qties=None): + response = self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assert_qty_done(move_lines, qties=qties) + self.assert_response_deliver(response, picking=self.picking) + + def test_set_qty_done_pack_picking_not_found(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + response = self.service.dispatch( + "set_qty_done_pack", params={"package_id": package.id, "picking_id": -1} + ) + self.assert_response_deliver( + response, message=self.service.msg_store.stock_picking_not_found() + ) + + def test_set_qty_done_pack_picking_canceled(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + self.picking.action_cancel() + response = self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package.id, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.stock_picking_not_available(self.picking), + ) + + def test_set_qty_done_pack_package_not_found(self): + response = self.service.dispatch( + "set_qty_done_pack", + params={"package_id": -1, "picking_id": self.picking.id}, + ) + self.assert_response_deliver( + response, + picking=self.picking, + message=self.service.msg_store.package_not_found(), + ) + + def test_set_qty_done_pack_multiple_product_ok(self): + move_lines = self.pack1_moves.mapped("move_line_ids") + package = move_lines.mapped("package_id") + self._test_set_qty_done_pack_ok(move_lines, package) + + def test_set_qty_done_pack_one_product_ok(self): + move_lines = self.pack2_move.mapped("move_line_ids") + package = move_lines.mapped("package_id") + self._test_set_qty_done_pack_ok(move_lines, package) + + def test_set_qty_done_pack_product_in_multiple_packages_ok(self): + move_lines = self.pack3_move.mapped("move_line_ids") + first_package = move_lines.mapped("package_id")[0] + self._test_set_qty_done_pack_ok( + move_lines, + # first_package done, not the second + first_package, + qties=[5, 0], + ) + + def test_set_qty_done_pack_picking_done(self): + pack1_move_lines = self.pack1_moves.mapped("move_line_ids") + package1 = pack1_move_lines.mapped("package_id") + pack2_move_lines = self.pack2_move.mapped("move_line_ids") + package2 = pack2_move_lines.mapped("package_id") + pack3_move_lines = self.pack3_move.mapped("move_line_ids") + packages3 = pack3_move_lines.mapped("package_id") + # process first package + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package1.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + # process second package + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": package2.id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + # process third package + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": packages3[0].id, "picking_id": self.picking.id}, + ) + self.assertEqual(self.picking.state, "assigned") + # process last package + self.service.dispatch( + "set_qty_done_pack", + params={"package_id": packages3[1].id, "picking_id": self.picking.id}, + ) + # picking is done once all its moves have been processed + self.assertEqual(self.picking.state, "done") diff --git a/shopfloor/tests/test_delivery_sublocation.py b/shopfloor/tests/test_delivery_sublocation.py new file mode 100644 index 0000000000..5c19812fd1 --- /dev/null +++ b/shopfloor/tests/test_delivery_sublocation.py @@ -0,0 +1,180 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_delivery_base import DeliveryCommonCase + +# pylint: disable=missing-return + + +class DeliveryScanSublocationCase(DeliveryCommonCase): + """Tests sublocation with delivery service.""" + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_e.tracking = "lot" + # Picking for the top location + cls.picking = picking = cls._create_picking( + lines=[ + (cls.product_d, 10), # D as raw product + (cls.product_e, 10), # E as raw product with a lot + ] + ) + cls.raw_move = picking.move_ids[0] + cls.raw_lot_move = picking.move_ids[1] + cls._fill_stock_for_moves(cls.raw_move) + cls._fill_stock_for_moves(cls.raw_lot_move, in_lot=True) + picking.action_assign() + cls.lot = cls.raw_lot_move.move_line_ids.lot_id + # Create a sublocation + cls.sublocation = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Output 1", + "location_id": cls.picking_type.default_location_src_id.id, + "usage": "internal", + "barcode": "WH-OUTPUT-1", + } + ) + ) + # Picking for the sublocation + cls.picking_sublocation = cls._create_picking( + lines=[ + (cls.product_d, 10), # D as raw product + (cls.product_e, 10), # E as raw product with a lot + ], + location_id=cls.sublocation, + ) + cls.raw_move_sublocation = cls.picking_sublocation.move_ids[0] + cls.raw_lot_move_sublocation = cls.picking_sublocation.move_ids[1] + cls._fill_stock_for_moves(cls.raw_move_sublocation, location=cls.sublocation) + # Use the same lot on product from both picking + cls._fill_stock_for_moves( + cls.raw_lot_move_sublocation, in_lot=cls.lot, location=cls.sublocation + ) + cls.picking_sublocation.action_assign() + + def test_scan_sublocation_exists(self): + """Check scanning a sublocation sets it as sublocation.""" + response = self.service.dispatch( + "scan_deliver", + params={ + "barcode": self.sublocation.barcode, + "picking_id": None, + "location_id": None, + }, + ) + self.assert_response_deliver( + response, + picking=None, + location=self.sublocation, + message=self.service.msg_store.location_src_set_to_sublocation( + self.sublocation + ), + ) + + def test_scan_invalid_barcode_in_sublocation(self): + response = self.service.dispatch( + "scan_deliver", + params={ + "barcode": "NO VALID BARCODE", + "picking_id": None, + "location_id": self.sublocation.id, + }, + ) + self.assert_response_deliver( + response, + location=self.sublocation, + message=self.service.msg_store.barcode_not_found(), + ) + + def test_scan_barcode_in_sublocation(self): + """Scan product barcode that exists in sublocation.""" + + response = self.service.dispatch( + "scan_deliver", + params={ + "barcode": self.product_d.barcode, + "location_id": self.sublocation.id, + }, + ) + self.assert_response_deliver( + response, + location=self.sublocation, + picking=self.picking_sublocation, + ) + + def test_scan_product_not_in_sublocation(self): + """Scan a product in picking type location but not in sublocation set.""" + response = self.service.dispatch( + "scan_deliver", + params={ + "barcode": self.product_c.barcode, + "picking_id": None, + "location_id": self.sublocation.id, + }, + ) + self.assert_response_deliver( + response, + location=self.sublocation, + message=self.service.msg_store.product_not_found_in_pickings(), + ) + + def test_scan_product_exist_in_multiple_sublocation(self): + """Check scan of product in multiple location will ask to scan a location.""" + response = self.service.dispatch( + "scan_deliver", + params={ + "barcode": self.product_d.barcode, + "picking_id": None, + }, + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.product_in_multiple_sublocation( + self.product_d + ), + ) + + def test_list_stock_picking_sublocation(self): + """Check manual selection filter picking in sublocation.""" + response = self.service.dispatch( + "list_stock_picking", params={"location_id": self.sublocation.id} + ) + self.assert_response_manual_selection( + response, + pickings=self.picking_sublocation, + ) + + def test_scan_lot_in_sublocation(self): + """Scan a lot that exists in sublocation.""" + lot = self.raw_lot_move_sublocation.move_line_ids.lot_id + response = self.service.dispatch( + "scan_deliver", + params={ + "barcode": lot.name, + "picking_id": None, + "location_id": self.sublocation.id, + }, + ) + self.assert_response_deliver( + response, + location=self.sublocation, + picking=self.picking_sublocation, + ) + + def test_scan_lot_exist_in_multiple_sublocation(self): + """Check scanning lot in multiple location, will ask location scan first.""" + response = self.service.dispatch( + "scan_deliver", + params={ + "barcode": self.lot.name, + "picking_id": None, + }, + ) + self.assert_response_deliver( + response, + message=self.service.msg_store.lot_in_multiple_sublocation(self.lot), + ) diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py new file mode 100644 index 0000000000..21e8ed5b6b --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -0,0 +1,136 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .common import CommonCase + +# pylint: disable=missing-return + + +class LocationContentTransferCommonCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref( + "shopfloor.shopfloor_menu_demo_location_content_transfer" + ) + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.content_loc = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Content Location", + "barcode": "Content", + "location_id": cls.picking_type.default_location_src_id.id, + } + ) + ) + + def setUp(self): + super().setUp() + self.service = self.get_service( + "location_content_transfer", menu=self.menu, profile=self.profile + ) + self.stock_action = self.service._actions_for("stock") + + def _simulate_selected_move_line(self, move_line): + """Mark the move line as picked (as it's done into the scan_location method)""" + self.stock_action.mark_move_line_as_picked(move_line) + + @classmethod + def _simulate_pickings_selected(cls, pickings): + """Create a state as if pickings has been selected + + ... during a Location content transfer. + + It means a user scanned the location with the pickings. They are: + + * assigned to the user + * the qty_done of all their move lines is set to they reserved qty + + """ + pickings.user_id = cls.env.uid + for line in pickings.mapped("move_line_ids"): + line.qty_done = line.reserved_uom_qty + + def assert_response_start(self, response, message=None, popup=None): + self.assert_response( + response, next_state="scan_location", message=message, popup=popup + ) + + def _assert_response_scan_destination_all( + self, state, response, pickings, message=None, confirmation_required=False + ): + # this code is repeated from the implementation, not great, but we + # mostly want to ensure the selection of pickings is right, and the + # data methods have their own tests + move_lines = pickings.move_line_ids + lines = move_lines.filtered(lambda line: not line.package_level_id) + package_levels = pickings.package_level_ids + location = move_lines.location_id + self.assert_response( + response, + next_state=state, + data={ + "move_lines": self.data.move_lines(lines), + "package_levels": self.data.package_levels(package_levels), + "location": self.data.location(location), + "confirmation_required": confirmation_required, + }, + message=message, + ) + + def assert_response_scan_destination_all( + self, response, pickings, message=None, confirmation_required=False + ): + self._assert_response_scan_destination_all( + "scan_destination_all", + response, + pickings, + message=message, + confirmation_required=confirmation_required, + ) + + def assert_response_start_single( + self, response, pickings, message=None, popup=None + ): + sorter = self.service._actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + location = pickings.mapped("location_id") + self.assert_response( + response, + next_state="start_single", + data=self.service._data_content_line_for_location(location, next(sorter)), + message=message, + popup=popup, + ) + + def _assert_response_scan_destination( + self, state, response, next_content, message=None, confirmation_required=False + ): + location = next_content.location_id + data = self.service._data_content_line_for_location(location, next_content) + data["confirmation_required"] = confirmation_required + self.assert_response( + response, + next_state=state, + data=data, + message=message, + ) + + def assert_response_scan_destination( + self, response, next_content, message=None, confirmation_required=False + ): + self._assert_response_scan_destination( + "scan_destination", + response, + next_content, + message=message, + confirmation_required=confirmation_required, + ) diff --git a/shopfloor/tests/test_location_content_transfer_get_work.py b/shopfloor/tests/test_location_content_transfer_get_work.py new file mode 100644 index 0000000000..287dcfddbc --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_get_work.py @@ -0,0 +1,125 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from .test_location_content_transfer_base import LocationContentTransferCommonCase + +# pylint: disable=missing-return + + +class TestLocationContentTransferGetWork(LocationContentTransferCommonCase): + """Tests for getting work + + Endpoints: + + * /find_work + * /cancel_work + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.menu.sudo().allow_get_work = True + cls.pickings = cls.env["stock.picking"].search( + [("location_id", "=", cls.stock_location.id)] + ) + cls.move_lines = cls.pickings.move_line_ids.filtered( + lambda line: line.qty_done == 0 + and line.state in ("assigned", "partially_available") + and not line.shopfloor_user_id + ) + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls.content_loc2 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Content Location 2", + "barcode": "Content2", + "location_id": cls.picking_type.default_location_src_id.id, + } + ) + ) + cls._fill_stock_for_moves( + picking1.move_ids, in_package=True, location=cls.content_loc + ) + cls._fill_stock_for_moves(picking2.move_ids[0], location=cls.content_loc2) + cls._fill_stock_for_moves(picking2.move_ids[1], location=cls.content_loc) + cls.pickings.action_assign() + + def _get_location_lines(self, location): + return self.env["stock.move.line"].search([("location_id", "=", location.id)]) + + def test_get_work(self): + """Check the first state is get_work when the option is enabled.""" + response = self.service.dispatch("start_or_recover", params={}) + self.assert_response( + response, + next_state="get_work", + data={}, + ) + + def test_find_work_no_work_found(self): + """Check the user asked for work but none is found.""" + # Cancel all moves related to the location we work from + self.pickings.move_ids.filtered(lambda r: r.state != "done")._action_cancel() + response = self.service.dispatch("find_work", params={}) + self.assert_response( + response, + next_state="get_work", + data={}, + message=self.service.msg_store.no_work_found(), + ) + + def test_find_work_work_found(self): + """Check the user is offered a location to work from.""" + next_location = self.service._find_location_to_work_from() + response = self.service.dispatch("find_work", params={}) + self.assert_response( + response, + next_state="scan_location", + data={ + "location": self.data.location(next_location), + }, + ) + lines = self._get_location_lines(next_location) + self.assertEqual(lines.shopfloor_user_id, self.env.user) + # Confirm the location + response = self.service.dispatch( + "scan_location", params={"barcode": next_location.name} + ) + self.assertEqual(response["next_state"], "scan_destination_all") + + def test_cancel_work(self): + next_location = self.service._find_location_to_work_from() + stock = self.service._actions_for("stock") + location_lines = self._get_location_lines(next_location) + stock.mark_move_line_as_picked(location_lines, quantity=0) + location_lines = self._get_location_lines(next_location) + self.assertEqual(location_lines.shopfloor_user_id, self.env.user) + response = self.service.dispatch( + "cancel_work", params={"location_id": next_location.id} + ) + self.assert_response( + response, + next_state="get_work", + data={}, + message={}, + ) + lines = self._get_location_lines(next_location) + self.assertFalse(lines.shopfloor_user_id) diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py new file mode 100644 index 0000000000..45adf17ad2 --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -0,0 +1,509 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_location_content_transfer_base import LocationContentTransferCommonCase + +# pylint: disable=missing-return + + +class LocationContentTransferMixCase(LocationContentTransferCommonCase): + """Tests where we mix location content transfer with other scenarios.""" + + @classmethod + def setUpClassUsers(cls): + super().setUpClassUsers() + Users = ( + cls.env["res.users"] + .sudo() + .with_context( + **{"no_reset_password": True, "mail_create_nosubscribe": True} + ) + ) + cls.stock_user2 = Users.create( + { + "name": "Paul Posichon", + "login": "paulposichon", + "email": "paul.posichon@example.com", + "notification_type": "inbox", + "groups_id": [(6, 0, [cls.env.ref("stock.group_stock_user").id])], + } + ) + + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.zp_menu = cls.env.ref("shopfloor.shopfloor_menu_demo_zone_picking") + cls.wh.sudo().delivery_steps = "pick_pack_ship" + cls.pack_location = cls.wh.wh_pack_stock_loc_id + cls.ship_location = cls.wh.wh_output_stock_loc_id + # Allows zone picking to process PICK picking type + cls.zp_menu.sudo().picking_type_ids += cls.wh.pick_type_id + # Allows location content transfer to process PACK picking type + cls.menu.sudo().picking_type_ids = cls.wh.pack_type_id + cls.wh.pack_type_id.sudo().default_location_dest_id = cls.env.ref( + "stock.stock_location_output" + ) + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.packing_location.sudo().active = True + products = cls.product_a + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + # Put product_a quantities in different packages to get + # two stock move lines (6 and 4 to satisfy 10 qties) + cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"}) + cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"}) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 6, package=cls.package_1 + ) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 4, package=cls.package_2 + ) + # Create the pick/pack/ship transfers + cls.ship_move_a = cls.env["stock.move"].create( + { + "name": cls.product_a.display_name, + "product_id": cls.product_a.id, + "product_uom_qty": 10.0, + "product_uom": cls.product_a.uom_id.id, + "location_id": cls.ship_location.id, + "location_dest_id": cls.customer_location.id, + "warehouse_id": cls.wh.id, + "picking_type_id": cls.wh.out_type_id.id, + "procure_method": "make_to_order", + "state": "draft", + } + ) + cls.ship_move_a._assign_picking() + cls.ship_move_a._action_confirm() + cls.pack_move_a = cls.ship_move_a.move_orig_ids[0] + cls.pick_move_a = cls.pack_move_a.move_orig_ids[0] + cls.picking1 = cls.pick_move_a.picking_id + cls.packing1 = cls.pack_move_a.picking_id + cls.picking1.action_assign() + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.zp_menu, profile=self.profile) as work: + self.zp_service = work.component(usage="zone_picking") + + def _zone_picking_process_line(self, move_line, dest_location=None): + picking = move_line.picking_id + zone_location = picking.location_id + picking_type = picking.picking_type_id + self.zp_service.work.current_zone_location = zone_location + self.zp_service.work.current_picking_type = picking_type + move_lines = picking.move_line_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ) + # Select the picking type + response = self.zp_service.scan_location(barcode=zone_location.barcode) + available_picking_type_ids = [ + r["id"] for r in response["data"]["select_picking_type"]["picking_types"] + ] + assert picking_type.id in available_picking_type_ids + assert "message" not in response + # Check the move lines related to the picking type + response = self.zp_service.list_move_lines() + available_move_line_ids = [ + r["id"] for r in response["data"]["select_line"]["move_lines"] + ] + assert not set(move_lines.ids) - set(available_move_line_ids) + assert "message" not in response + # Set the destination on the move line + if not dest_location: + dest_location = move_line.location_dest_id + qty = move_line.reserved_uom_qty + response = self.zp_service.set_destination( + move_line.id, + dest_location.barcode, + qty, + confirmation=True, + ) + assert response["message"]["message_type"] == "success" + self.assertEqual(move_line.state, "done") + self.assertEqual(move_line.move_id.product_uom_qty, qty) + + def _location_content_transfer_process_line( + self, move_line, set_destination=False, user=None + ): + service = self.service + if user: + env = self.env(user=user) + service = self.get_service( + "location_content_transfer", + env=env, + menu=self.menu, + profile=self.profile, + ) + + pack_location = move_line.location_id + out_location = move_line.location_dest_id + # Scan the location + response = service.scan_location(pack_location.barcode) + # Set the destination + if set_destination: + assert response["next_state"] in ("scan_destination_all", "start_single") + qty = move_line.reserved_uom_qty + if response["next_state"] == "scan_destination_all": + response = service.set_destination_all( + pack_location.id, out_location.barcode + ) + self.assert_response_start( + response, + message=service.msg_store.location_content_transfer_complete( + pack_location, + out_location, + ), + ) + self.assertEqual(move_line.state, "done") + self.assertEqual(move_line.move_id.product_uom_qty, qty) + elif response["next_state"] == "start_single": + response = service.scan_line( + pack_location.id, move_line.id, move_line.product_id.barcode + ) + assert response["message"]["message_type"] == "success" + response = service.set_destination_line( + pack_location.id, move_line.id, qty, out_location.barcode + ) + assert response["message"]["message_type"] == "success" + assert move_line.state == "done" + assert move_line.qty_done == qty + return response + + def test_with_zone_picking1(self): + """Test the following scenario: + + 1) Operator-1 processes the first pallet with the "zone picking" scenario: + + move1 PICK -> PACK 'done' + + 2) Operator-2 with the "location content transfer" scenario scan + the location where this first pallet is (so the move line is still not + done, the operator is currently moving the goods to the destination location): + + move1 PACK -> SHIP 'assigned' while the operator is moving it + + 3) Operator-1 process the second pallet with the "zone picking" scenario: + + move2 PICK -> PACK 'done' + + 4) Operator-3 with the "location content transfer" scenario scan + the location where this second pallet is, Odoo should return only this + second pallet as the first one, even if not fully processed (done) + is not physically available in the scanned location. + + move2 PACK -> SHIP 'assigned' is proposed to the operator + move1 PACK -> SHIP while still 'assigned' is not proposed to the operator + """ + picking = self.picking1 + move_lines = picking.move_line_ids + pick_move_line1 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_1 + ) + pick_move_line2 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_2 + ) + # Operator-1 process the first pallet with the "zone picking" scenario + self._zone_picking_process_line(pick_move_line1) + # Operator-2 with the "location content transfer" scenario scan + # the location where this first pallet is (so the move line is still not + # done, the operator is currently moving the goods to the destination location) + pack_move_line1 = pick_move_line1.move_id.move_dest_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ).move_line_ids.filtered(lambda l: not l.shopfloor_user_id) + self._location_content_transfer_process_line(pack_move_line1) + # Operator-1 process the second pallet with the "zone picking" scenario + self._zone_picking_process_line(pick_move_line2) + # Operator-3 with the "location content transfer" scenario scan + # the location where this second pallet is + pack_move_line2 = pick_move_line2.move_id.move_dest_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ).move_line_ids.filtered(lambda l: not l.shopfloor_user_id) + assert ( + len(pack_move_line2) == 1 + ), "Operator-3 should end up with one move line taken from {}".format( + pack_move_line2.picking_id.name + ) + self._location_content_transfer_process_line(pack_move_line2) + + def test_with_zone_picking2(self): + """Test the following scenario: + + 1) Operator-1 processes the first pallet with the "zone picking" scenario + to move the goods to PACK-1 and unload in destination location1: + + move1 PICK -> PACK-1 'done' + + 2) Operator-1 processes the second pallet with the "zone picking" scenario + to move the goods to PACK-2 and unload in destination location2: + + move1 PICK -> PACK-2 'done' + + 3) Operator-2 with the "location content transfer" scenario scan + the location where the first pallet is (PACK-1): + - the app should found one move line + - this move line will be put in its own transfer as its sibling lines + are in another source location + - as such the app should ask the destination location (as there is + only one line) + + move1 PACK-2 -> SHIP (still handled by the operator so not 'done') + + 4) Operator-3 with the "location content transfer" scenario scan + the location where the first pallet is (PACK-1): + - nothing is found as the pallet is currently handled by Operator-2 + + 5) If Operator-2 is unable to finish the flow with the first pallet + (barcode device out of battery... etc), he should be able to recover + what he started. + + 6) Operator-2 then finishes its operation regarding the first pallet, and + scan the location where the second pallet is (PACK-2). He should find + only this pallet available. + """ + move_lines = self.picking1.move_line_ids + pick_move_line1 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_1 + ) + pick_move_line2 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_2 + ) + # Operator-1 process the first pallet with the "zone picking" scenario + orig_dest_location = pick_move_line1.location_dest_id + dest_location1 = pick_move_line1.location_dest_id.sudo().copy( + { + "name": orig_dest_location.name + "_1", + "barcode": orig_dest_location.barcode + "_1", + "location_id": orig_dest_location.id, + } + ) + self._zone_picking_process_line(pick_move_line1, dest_location=dest_location1) + # Operator-1 process the second pallet with the "zone picking" scenario + dest_location2 = orig_dest_location.sudo().copy( + { + "name": orig_dest_location.name + "_2", + "barcode": orig_dest_location.barcode + "_2", + "location_id": orig_dest_location.id, + } + ) + self._zone_picking_process_line(pick_move_line2, dest_location=dest_location2) + pack_move_a = pick_move_line1.move_id.move_dest_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(pack_move_a, self.pack_move_a) + pack_first_pallet = pack_move_a.move_line_ids.filtered( + lambda l: not l.shopfloor_user_id and l.location_id == dest_location1 + ) + self.assertEqual(pack_first_pallet.reserved_uom_qty, 6) + self.assertEqual(pack_first_pallet.qty_done, 0) + pack_second_pallet = pack_move_a.move_line_ids.filtered( + lambda l: not l.shopfloor_user_id and l.location_id == dest_location2 + ) + self.assertEqual(pack_second_pallet.reserved_uom_qty, 4) + self.assertEqual(pack_second_pallet.qty_done, 0) + # Operator-2 with the "location content transfer" scenario scan + # the location where the first pallet is. + # This pallet/move line will be put in its own transfer as its sibling + # lines are in another source location. + previous_picking = pack_first_pallet.picking_id + response = self._location_content_transfer_process_line(pack_first_pallet) + new_picking = pack_first_pallet.picking_id + self.assertTrue(previous_picking != new_picking) + self.assert_response_scan_destination_all(response, new_picking) + response_packages = response["data"]["scan_destination_all"]["package_levels"] + self.assertEqual(len(response_packages), 1) + self.assertEqual( + response_packages[0]["package_src"]["id"], pack_first_pallet.package_id.id + ) + # Ensure that the second pallet is untouched + self.assertEqual(pack_second_pallet.qty_done, 0) + # Operator-3 with the "location content transfer" scenario scan + # the location where the first pallet is: he should found nothing + response = self._location_content_transfer_process_line( + pack_first_pallet, user=self.stock_user2 + ) + self.assert_response_start( + response, message=self.service.msg_store.new_move_lines_not_assigned() + ) + # Check if Operator-2 is able to recover its session + expected_picking = pack_first_pallet.picking_id + response = self.service.start_or_recover() + self.assert_response_scan_destination_all( + response, + expected_picking, + message=self.service.msg_store.recovered_previous_session(), + ) + # Operator-2 finishes its operation regarding the first pallet + qty = pack_first_pallet.reserved_uom_qty + response = self.service.set_destination_all( + pack_first_pallet.location_id.id, pack_first_pallet.location_dest_id.barcode + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_content_transfer_complete( + pack_first_pallet.location_id, + pack_first_pallet.location_dest_id, + ), + ) + self.assertEqual(pack_first_pallet.qty_done, 6) + self.assertEqual(pack_first_pallet.state, "done") + self.assertEqual(pack_first_pallet.move_id.product_uom_qty, qty) + # Ensure that the second pallet is untouched + self.assertEqual(pack_second_pallet.qty_done, 0) + # Operator-2 (still with the "location content transfer" scenario) scan + # the location where the second pallet is + pack_move_a = pick_move_line2.move_id.move_dest_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(pack_move_a, self.pack_move_a) + pack_second_pallet = pack_move_a.move_line_ids.filtered( + lambda l: not l.shopfloor_user_id and l.location_id == dest_location2 + ) + picking_before = pack_second_pallet.picking_id + move_lines = self.service._find_location_move_lines( + pack_second_pallet.location_id + ) + response = self._location_content_transfer_process_line(pack_second_pallet) + response_packages = response["data"]["scan_destination_all"]["package_levels"] + self.assertEqual(len(response_packages), 1) + self.assertEqual( + response_packages[0]["package_src"]["id"], pack_second_pallet.package_id.id + ) + picking_after = pack_second_pallet.picking_id + self.assertEqual(picking_before, picking_after) + self.assert_response_scan_destination_all(response, picking_after) + + def test_with_zone_picking3(self): + """Test the following scenario: + + 1) Operator-1 processes the first pallet with the "zone picking" scenario + to move the goods to PACK-1: + + move1 PICK -> PACK-1 'done' + + 2) Operator-2 with the "location content transfer" scenario scan + the location where the first pallet is (PACK-1): + - the app should found one move line + - this move line will be put in its own transfer in any case + - as such the app should ask the destination location (as there is + only one line) + + move1 PACK-1 -> SHIP (still handled by the operator so not 'done') + + 3) Operator-1 processes the second pallet with the "zone picking" scenario + to move the goods to PACK-2: + + move1 PICK -> PACK-2 'done' + + - this will automatically update the reservation (new move line) in + the transfer previously processed by Operator-2. + + 4) Operator-2 then finishes its operation regarding the first pallet + without any trouble. + + 5) Operator-2 with the "location content transfer" scenario scan + the location where the second pallet is (PACK-2), etc + """ + move_lines = self.picking1.move_line_ids + pick_move_line1 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_1 + ) + pick_move_line2 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_2 + ) + orig_dest_location = pick_move_line1.location_dest_id + dest_location1 = pick_move_line1.location_dest_id.sudo().copy( + { + "name": orig_dest_location.name + "_1", + "barcode": orig_dest_location.barcode + "_1", + "location_id": orig_dest_location.id, + } + ) + dest_location2 = orig_dest_location.sudo().copy( + { + "name": orig_dest_location.name + "_2", + "barcode": orig_dest_location.barcode + "_2", + "location_id": orig_dest_location.id, + } + ) + # Operator-1 process the first pallet with the "zone picking" scenario + self._zone_picking_process_line(pick_move_line1, dest_location=dest_location1) + pack_move_a1 = pick_move_line1.move_id.move_dest_ids.filtered( + lambda m: m.move_line_ids.package_id == self.package_1 + ) + self.assertEqual(pack_move_a1, self.pack_move_a) + pack_first_pallet = pack_move_a1.move_line_ids.filtered( + lambda l: not l.shopfloor_user_id and l.location_id == dest_location1 + ) + self.assertEqual(pack_first_pallet.reserved_uom_qty, 6) + self.assertEqual(pack_first_pallet.qty_done, 0) + # Operator-2 with the "location content transfer" scenario scan + # the location where the first pallet is. + # This pallet/move line will be put in its own move and transfer by convenience + original_pack_transfer = pack_first_pallet.picking_id + response = self._location_content_transfer_process_line(pack_first_pallet) + new_pack_transfer = pack_first_pallet.picking_id + self.assertNotEqual(original_pack_transfer, new_pack_transfer) + self.assert_response_scan_destination_all(response, new_pack_transfer) + response_packages = response["data"]["scan_destination_all"]["package_levels"] + self.assertEqual(len(response_packages), 1) + self.assertEqual( + response_packages[0]["package_src"]["id"], pack_first_pallet.package_id.id + ) + # All pack lines have been processed until now, so the existing pack + # operation is now waiting goods from pick operation + self.assertEqual(original_pack_transfer.state, "waiting") + # Operator-1 process the second pallet with the "zone picking" scenario + self._zone_picking_process_line(pick_move_line2, dest_location=dest_location2) + pack_move_a2 = pick_move_line2.move_id.move_dest_ids.filtered( + lambda m: m.move_line_ids.package_id == self.package_2 + ) + pack_second_pallet = pack_move_a2.move_line_ids.filtered( + lambda l: not l.shopfloor_user_id and l.location_id == dest_location2 + ) + self.assertEqual(pack_second_pallet.reserved_uom_qty, 4) + self.assertEqual(pack_second_pallet.qty_done, 0) + # The last action has updated the pack operation (new move line) in the + # transfer previously processed by Operator-2. + self.assertEqual(original_pack_transfer.state, "assigned") + self.assertIn(self.package_2, original_pack_transfer.move_line_ids.package_id) + # Operator-2 finishes its operation regarding the first pallet without + # any trouble as the processed move line has been put in its own + # move+transfer + qty = pack_first_pallet.reserved_uom_qty + response = self.service.set_destination_all( + pack_first_pallet.location_id.id, pack_first_pallet.location_dest_id.barcode + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_content_transfer_complete( + pack_first_pallet.location_id, + pack_first_pallet.location_dest_id, + ), + ) + self.assertEqual(pack_first_pallet.qty_done, 6) + self.assertEqual(pack_first_pallet.state, "done") + self.assertEqual(pack_first_pallet.move_id.product_uom_qty, qty) + # Operator-2 with the "location content transfer" scenario scan + # the location where the second pallet is. + original_pack_transfer = pack_second_pallet.picking_id + response = self._location_content_transfer_process_line(pack_second_pallet) + new_pack_transfer = pack_second_pallet.picking_id + # Transfer hasn't been split as we were processing the last line/pallet + self.assertEqual(original_pack_transfer, new_pack_transfer) + self.assert_response_scan_destination_all(response, new_pack_transfer) + response_packages = response["data"]["scan_destination_all"]["package_levels"] + self.assertEqual(len(response_packages), 1) + self.assertEqual( + response_packages[0]["package_src"]["id"], pack_second_pallet.package_id.id + ) diff --git a/shopfloor/tests/test_location_content_transfer_putaway.py b/shopfloor/tests/test_location_content_transfer_putaway.py new file mode 100644 index 0000000000..fb073f8bbc --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_putaway.py @@ -0,0 +1,143 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_location_content_transfer_base import LocationContentTransferCommonCase + +# pylint: disable=missing-return + + +class TestLocationContentTransferPutaway(LocationContentTransferCommonCase): + """Tests with putaway when using option to ignore unavailable putaway locations""" + + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.pallets_storage_type = cls.env.ref( + "stock_storage_type.package_storage_type_pallets" + ) + cls.main_pallets_location = cls.env.ref( + "stock_storage_type.stock_location_pallets" + ) + cls.reserve_pallets_locations = cls.env.ref( + "stock_storage_type.stock_location_pallets_reserve" + ) + cls.all_pallets_locations = ( + cls.main_pallets_location.leaf_location_ids + | cls.reserve_pallets_locations.leaf_location_ids + ) + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.package = cls.env["stock.quant.package"].create( + { + # this will parameterize the putaway to use pallet locations, + # and if not, it will stay on the picking type's default dest. + "package_type_id": cls.pallets_storage_type.id, + } + ) + cls.package2 = cls.env["stock.quant.package"].create( + { + # this will parameterize the putaway to use pallet locations, + # and if not, it will stay on the picking type's default dest. + "package_type_id": cls.pallets_storage_type.id, + } + ) + # create a location to be sure it's empty + cls.test_loc = ( + cls.env["stock.location"] + .sudo() + .create( + { + "location_id": cls.stock_location.id, + "name": "test", + "barcode": "test_loc", + } + ) + ) + cls._update_qty_in_location( + cls.test_loc, cls.product_a, 10, package=cls.package + ) + cls._update_qty_in_location( + cls.test_loc, cls.product_a, 10, package=cls.package2 + ) + cls.menu.sudo().allow_move_create = True + cls.menu.sudo().ignore_no_putaway_available = True + cls.menu.sudo().allow_unreserve_other_moves = True + + def test_normal_putaway(self): + """Ensure putaway is applied on moves""" + response = self.service.dispatch( + "scan_location", params={"barcode": self.test_loc.barcode} + ) + self.assert_response( + response, + next_state="start_single", + data=self.ANY, + ) + package_level_id = response["data"]["start_single"]["package_level"]["id"] + package_level = self.env["stock.package_level"].browse(package_level_id) + self.assertIn(package_level.location_dest_id, self.all_pallets_locations) + + def test_ignore_no_putaway_available(self): + """Ignore no putaway available is activated on the menu + + In this case, when no putaway is possible, the changes + are rollbacked and an error is returned. + """ + for location in self.all_pallets_locations: + package = self.env["stock.quant.package"].create( + {"package_type_id": self.pallets_storage_type.id} + ) + self._update_qty_in_location(location, self.product_a, 10, package=package) + + response = self.service.dispatch( + "scan_location", params={"barcode": self.test_loc.barcode} + ) + self.assert_response( + response, + next_state="scan_location", + message=self.service.msg_store.no_putaway_destination_available(), + ) + + package_levels = self.env["stock.package_level"].search( + [("package_id", "in", (self.package.id, self.package2.id))] + ) + # no package level created to move the package + self.assertFalse(package_levels) + + def test_putaway_move_dest_not_child_of_picking_type_dest(self): + """Putaway is applied on move but the destination location is not a + child of the default picking type destination location. + """ + # Change the default destination location of the picking type + # to get it outside of the putaway destination + self.picking_type.sudo().default_location_dest_id = self.main_pallets_location + # Create a standard putaway to move the package from pallet storage + # to a unrelated one (outside of the pallet storage tree) + self.env["stock.putaway.rule"].sudo().create( + { + "product_id": self.product_a.id, + "location_in_id": self.picking_type.default_location_dest_id.id, + "location_out_id": self.env.ref("stock.location_refrigerator_small").id, + } + ) + # Check the result + existing_moves = self.env["stock.move"].search( + [("location_id", "=", self.test_loc.id), ("state", "=", "assigned")] + ) + response = self.service.dispatch( + "scan_location", params={"barcode": self.test_loc.barcode} + ) + self.assert_response( + response, + next_state="scan_location", + data=self.ANY, + message=self.service.msg_store.location_content_unable_to_transfer( + self.test_loc + ), + ) + current_moves = self.env["stock.move"].search( + [("location_id", "=", self.test_loc.id), ("state", "=", "assigned")] + ) + self.assertEqual(existing_moves, current_moves) diff --git a/shopfloor/tests/test_location_content_transfer_scan_location.py b/shopfloor/tests/test_location_content_transfer_scan_location.py new file mode 100644 index 0000000000..c10171c758 --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_scan_location.py @@ -0,0 +1,34 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from .test_location_content_transfer_base import LocationContentTransferCommonCase + +# pylint: disable=missing-return + + +class TestLocationContentTransferScanLocation(LocationContentTransferCommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + # One picking with shipping policy set on "When all products are ready" + # With only one of the move available in the stock + cls.picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking1.move_type = "one" + cls.move1 = cls.picking1.move_ids[0] + cls._fill_stock_for_moves(cls.move1, in_package=False, location=cls.content_loc) + cls.picking1.action_assign() + # Another picking available + picking2 = cls._create_picking(lines=[(cls.product_c, 5)]) + cls._fill_stock_for_moves(picking2.move_ids, location=cls.content_loc) + picking2.action_assign() + + def test_lines_returned_by_scan_location(self): + """Check that lines from not ready pickings are not offered to work on.""" + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + lines = response["data"]["scan_destination_all"]["move_lines"] + line_ids = [line["id"] for line in lines] + self.assertTrue(self.move1.move_line_ids.id not in line_ids) diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py new file mode 100644 index 0000000000..fe14240d67 --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -0,0 +1,343 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_location_content_transfer_base import LocationContentTransferCommonCase + +# pylint: disable=missing-return + + +class LocationContentTransferSetDestinationAllCase(LocationContentTransferCommonCase): + """Tests for endpoint used from scan_destination_all + + * /set_destination_all + * /go_to_single + + """ + + # TODO see what can be common + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_ids, in_package=True, location=cls.content_loc + ) + cls._fill_stock_for_moves(picking2.move_ids, location=cls.content_loc) + cls.pickings.action_assign() + cls._simulate_pickings_selected(cls.pickings) + + def assert_all_done(self, destination): + self.assertRecordValues(self.pickings, [{"state": "done"}, {"state": "done"}]) + self.assertRecordValues( + self.pickings.move_line_ids, + [ + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + ], + ) + self.assertRecordValues( + self.picking1.package_level_ids, + [{"is_done": True, "state": "done", "location_dest_id": destination.id}], + ) + + def test_set_destination_all_dest_location_ok(self): + """Scanned destination location valid, moves set to done accepted""" + sub_shelf1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": self.shelf1.id, + } + ) + ) + response = self.service.dispatch( + "set_destination_all", + params={"location_id": self.content_loc.id, "barcode": sub_shelf1.barcode}, + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_content_transfer_complete( + self.content_loc, sub_shelf1 + ), + ) + self.assert_all_done(sub_shelf1) + + def test_set_destination_all_with_partially_available_move_without_ancestor(self): + """Scanned destination location valid, but one of the move to process + is partially available and has no ancestor move. + + In such case, normal backorder is created with the remaining qty while + the current pickings is validated. + """ + # Put a partial quantity for 'product_d' to get a partially available move + self.picking2.do_unreserve() + self._update_qty_in_location(self.content_loc, self.product_d, 5) + self.picking2.action_assign() + self._simulate_pickings_selected(self.picking2) + move_d = self.picking2.move_ids.filtered( + lambda m: m.product_id == self.product_d + ) + + sub_shelf1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": self.shelf1.id, + } + ) + ) + response = self.service.dispatch( + "set_destination_all", + params={"location_id": self.content_loc.id, "barcode": sub_shelf1.barcode}, + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_content_transfer_complete( + self.content_loc, sub_shelf1 + ), + ) + # As we have no ancestor move in progress, a normal backorder is created + # with the remaining qties + self.assertEqual(self.picking2.state, "done") + self.assertEqual(move_d.state, "done") + self.assertEqual(move_d.product_qty, 5) + self.assertTrue(self.picking2.backorder_ids) + self.assertNotEqual(self.picking2.backorder_ids.state, "done") + self.assertFalse(self.picking2.backorder_ids.user_id) + self.assertEqual(self.picking2.backorder_ids.move_ids.product_qty, 5) + + def test_set_destination_all_with_partially_available_move_with_ancestor(self): + """Scanned destination location valid, but one of the move to process + is partially available and has an unprocessed ancestor move. + + In such case, new picking is created to validate the moves, and the + remaining qties stay in their current picking. + """ + # Put a partial quantity for 'product_d' to get a partially available move + self.picking2.do_unreserve() + self._update_qty_in_location(self.content_loc, self.product_d, 5) + self.picking2.action_assign() + self._simulate_pickings_selected(self.picking2) + # Set an ancestor move on the partially available move + move_d = self.picking2.move_ids.filtered( + lambda m: m.product_id == self.product_d + ) + move_d.move_orig_ids |= move_d.copy({"picking_id": False}) + + sub_shelf1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": self.shelf1.id, + } + ) + ) + response = self.service.dispatch( + "set_destination_all", + params={"location_id": self.content_loc.id, "barcode": sub_shelf1.barcode}, + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_content_transfer_complete( + self.content_loc, sub_shelf1 + ), + ) + # The current picking with the remaining qties is waiting (because of the + # ancestor move), and other moves are validated in a new one. + self.assertEqual(self.picking2.state, "waiting") + self.assertEqual(self.picking2.move_ids.state, "waiting") + self.assertEqual(self.picking2.move_ids.product_qty, 5) + self.assertEqual(self.picking2.backorder_ids.state, "done") + self.assertEqual(move_d.state, "done") + self.assertEqual(move_d.product_qty, 5) + + def test_set_destination_all_dest_location_ok_with_completion_info(self): + """Scanned destination location valid, moves set to done accepted + and completion info is returned as the next transfer is ready. + """ + move = self.picking1.move_ids[0] + next_move = move.copy( + { + "location_id": move.location_dest_id.id, + "location_dest_id": self.customer_location.id, + "move_orig_ids": [(6, 0, move.ids)], + } + ) + next_move._action_confirm(merge=False) + next_move._assign_picking() + self.assertEqual(next_move.state, "waiting") + sub_shelf1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": self.shelf1.id, + } + ) + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + response = self.service.dispatch( + "set_destination_all", + params={"location_id": self.content_loc.id, "barcode": sub_shelf1.barcode}, + ) + self.assertEqual(next_move.state, "assigned") + completion_info = self.service._actions_for("completion.info") + completion_info_popup = completion_info.popup(move_lines) + self.assert_response_start( + response, + message=self.service.msg_store.location_content_transfer_complete( + self.content_loc, sub_shelf1 + ), + popup=completion_info_popup, + ) + + def test_set_destination_all_dest_location_not_found(self): + """Barcode scanned for destination location is not found""" + response = self.service.dispatch( + "set_destination_all", + params={"location_id": self.content_loc.id, "barcode": "NOT_FOUND"}, + ) + self.assert_response_scan_destination_all( + response, self.pickings, message=self.service.msg_store.barcode_not_found() + ) + + def test_set_destination_all_dest_location_need_confirm(self): + """Scanned dest. location != child but in picking type location + + So it needs confirmation. + """ + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + # expected location was shelf1, but shelf2 is valid as still in the + # picking type's default dest location, ask confirmation (second scan) + # from the user + "barcode": self.shelf2.barcode, + }, + ) + self.assert_response_scan_destination_all( + response, + self.pickings, + message=self.service.msg_store.need_confirmation(), + confirmation_required=True, + ) + + def test_set_destination_all_dest_location_confirmation(self): + """Scanned dest. location != child but in picking type location: confirm + + use the confirmation flag to confirm + """ + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + # expected location was shelf1, but shelf2 is valid as still in the + # picking type's default dest location, ask confirmation (second scan) + # from the user + "barcode": self.shelf2.barcode, + "confirmation": True, + }, + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_content_transfer_complete( + self.content_loc, self.shelf2 + ), + ) + self.assert_all_done(self.shelf2) + + def test_set_destination_all_dest_location_invalid(self): + """The scanned destination location is not in the menu's picking types""" + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + "barcode": self.dispatch_location.barcode, + }, + ) + self.assert_response_scan_destination_all( + response, + self.pickings, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_set_destination_all_dest_location_move_invalid(self): + """The scanned destination location is not in the picking and move's + dest location + """ + # if we have at least one move which does not match the scanned + # location + # we forbid the action + self.pickings.move_ids[0].location_dest_id = self.shelf1 + self.pickings[0].location_dest_id = self.shelf1 + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + "barcode": self.shelf2.barcode, + }, + ) + self.assert_response_scan_destination_all( + response, + self.pickings, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_go_to_single(self): + """User used to 'split by lines' button to process line per line""" + response = self.service.dispatch( + "go_to_single", params={"location_id": self.content_loc.id} + ) + self.assert_response_start_single(response, self.pickings) + + +class LocationContentTransferSetDestinationAllSpecialCase( + LocationContentTransferCommonCase +): + """Tests for endpoint used from scan_destination_all (special cases without setup) + + * /set_destination_all + * /go_to_single + + """ + + def test_go_to_single_no_lines_to_process(self): + """User used to 'split by lines' button to process line per line, + but no lines to process. + """ + response = self.service.dispatch( + "go_to_single", params={"location_id": self.content_loc.id} + ) + self.assert_response_start( + response, message=self.service.msg_store.no_lines_to_process() + ) diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py new file mode 100644 index 0000000000..ef0f488fbf --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -0,0 +1,1074 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_location_content_transfer_base import LocationContentTransferCommonCase + +# pylint: disable=missing-return + + +class LocationContentTransferSetDestinationXCase(LocationContentTransferCommonCase): + """Tests for endpoint used from scan_destination + + * /set_destination_package + * /set_destination_line + + """ + + # TODO see what can be common + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_ids, in_package=True, location=cls.content_loc + ) + cls._fill_stock_for_moves(picking2.move_ids, location=cls.content_loc) + cls.pickings.action_assign() + cls._simulate_pickings_selected(cls.pickings) + cls.dest_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": cls.shelf1.id, + } + ) + ) + cls.warehouse = cls.env.ref("stock.warehouse0") + + def test_set_destination_package_wrong_parameters(self): + """Wrong 'location' and 'package_level_id' parameters, redirect the + user to the 'start' screen. + """ + package_level = self.picking1.package_level_ids[0] + self._simulate_selected_move_line(package_level.move_line_ids) + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": package_level.id, + "barcode": "TEST", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": 1234567890, # Doesn't exist + "barcode": "TEST", + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) + + def test_set_destination_package_dest_location_nok(self): + """Scanned destination location not valid, redirect to 'scan_destination'.""" + package_level = self.picking1.package_level_ids[0] + self._simulate_selected_move_line(package_level.move_line_ids) + # Unknown destination location + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": "UNKNOWN_LOCATION", + }, + ) + self.assert_response_scan_destination( + response, + package_level, + message=self.service.msg_store.no_location_found(), + ) + # Destination location not allowed + customer_location = self.env.ref("stock.stock_location_customers") + customer_location.sudo().barcode = "CUSTOMER" + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": customer_location.barcode, + }, + ) + self.assert_response_scan_destination( + response, + package_level, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_set_destination_package_dest_location_move_nok(self): + """Scanned destination location not valid (different as move and picking)""" + package_level = self.picking1.package_level_ids[0] + # if the move related to the package level has a destination + # location not a parent or equal to the scanned location, + # refuse the action + move = package_level.move_line_ids.move_id + move.location_dest_id = self.shelf1 + move.picking_id.location_dest_id = self.shelf1 + self._simulate_selected_move_line(package_level.move_line_ids) + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.shelf2.barcode, + }, + ) + self.assert_response_scan_destination( + response, + package_level, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_set_destination_package_dest_location_to_confirm(self): + """Scanned destination location valid, but need a confirmation.""" + package_level = self.picking1.package_level_ids[0] + self._simulate_selected_move_line(package_level.move_line_ids) + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.env.ref("stock.stock_location_14").barcode, + }, + ) + self.assert_response_scan_destination( + response, + package_level, + message=self.service.msg_store.need_confirmation(), + confirmation_required=True, + ) + + def test_set_destination_package_dest_location_ok(self): + """Scanned destination location valid, moves set to done.""" + original_picking = self.picking1 + package_level = original_picking.package_level_ids[0] + self._simulate_selected_move_line(package_level.move_line_ids) + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + # Check the data (the whole transfer has been validated here w/o backorder) + self.assertFalse(original_picking.backorder_ids) + self.assertEqual(original_picking.state, "done") + self.assertEqual(package_level.state, "done") + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + ) + for move in package_level.move_line_ids.mapped("move_id"): + self.assertEqual(move.state, "done") + + def test_set_destination_package_dest_location_ok_with_completion_info(self): + """Scanned destination location valid, moves set to done + and completion info is returned as the next transfer is ready. + """ + original_picking = self.picking1 + package_level = original_picking.package_level_ids[0] + move = package_level.move_line_ids.move_id[0] + next_move = move.copy( + { + "picking_id": False, + "picking_type_id": self.warehouse.out_type_id.id, + "location_id": move.location_dest_id.id, + "location_dest_id": self.customer_location.id, + "move_orig_ids": [(6, 0, move.ids)], + } + ) + next_move._action_confirm(merge=False) + next_move._assign_picking() + self.assertEqual(next_move.state, "waiting") + self.assertTrue(next_move.picking_id) + self._simulate_selected_move_line(package_level.move_line_ids) + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + # Check the data (the whole transfer has been validated here w/o backorder) + self.assertFalse(original_picking.backorder_ids) + self.assertEqual(original_picking.state, "done") + self.assertEqual(package_level.state, "done") + self.assertEqual(next_move.state, "assigned") + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + completion_info = self.service._actions_for("completion.info") + completion_info_popup = completion_info.popup(package_level.move_line_ids) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + popup=completion_info_popup, + ) + for move in package_level.move_line_ids.mapped("move_id"): + self.assertEqual(move.state, "done") + + def test_set_destination_line_wrong_parameters(self): + """Wrong 'location' and 'move_line_id' parameters, redirect the + user to the 'start' screen. + """ + move_line = self.picking2.move_line_ids[0] + self._simulate_selected_move_line(move_line) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": 1234567890, # Doesn't exist + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty, + "barcode": "TEST", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": 1234567890, # Doesn't exist + "quantity": move_line.reserved_uom_qty, + "barcode": "TEST", + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) + + def test_set_destination_line_dest_location_nok(self): + """Scanned destination location not valid, redirect to 'scan_destination'.""" + move_line = self.picking2.move_line_ids[0] + self._simulate_selected_move_line(move_line) + # Unknown destination location + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty, + "barcode": "UNKNOWN_LOCATION", + }, + ) + self.assert_response_scan_destination( + response, + move_line, + message=self.service.msg_store.no_location_found(), + ) + # Destination location not allowed + customer_location = self.env.ref("stock.stock_location_customers") + customer_location.sudo().barcode = "CUSTOMER" + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty, + "barcode": customer_location.barcode, + }, + ) + self.assert_response_scan_destination( + response, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_set_destination_line_dest_location_move_nok(self): + """Scanned destination location not valid (different as picking and move)""" + move_line = self.picking2.move_line_ids[0] + # if the move related to the move line has a destination + # location not a parent or equal to the scanned location, + # refuse the action + move_line.move_id.location_dest_id = self.shelf1 + move_line.picking_id.location_dest_id = self.shelf1 + self._simulate_selected_move_line(move_line) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty, + "barcode": self.shelf2.barcode, + }, + ) + self.assert_response_scan_destination( + response, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_set_destination_line_dest_location_to_confirm(self): + """Scanned destination location valid, but need a confirmation.""" + move_line = self.picking2.move_line_ids[0] + self._simulate_selected_move_line(move_line) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty, + "barcode": self.env.ref("stock.stock_location_14").barcode, + }, + ) + self.assert_response_scan_destination( + response, + move_line, + message=self.service.msg_store.need_confirmation(), + confirmation_required=True, + ) + + def test_set_destination_line_dest_location_ok(self): + """Scanned destination location valid, moves set to done.""" + original_picking = self.picking2 + move_line = original_picking.move_line_ids[0] + self._simulate_selected_move_line(move_line) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + # Check the resulting data + # We got a new picking as the original one had two moves (and we + # validated only one) + new_picking = move_line.picking_id + self.assertTrue(new_picking != original_picking) + self.assertEqual(move_line.move_id.state, "done") + self.assertEqual(move_line.picking_id.state, "done") + self.assertEqual(original_picking.state, "assigned") + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + ) + + def test_set_destination_line_dest_location_ok_with_completion_info(self): + """Scanned destination location valid, moves set to done + and completion info is returned as the next transfer is ready. + """ + original_picking = self.picking2 + move_line = original_picking.move_line_ids[0] + move = move_line.move_id + next_move = move.copy( + { + "picking_id": False, + "picking_type_id": self.warehouse.out_type_id.id, + "location_id": move.location_dest_id.id, + "location_dest_id": self.customer_location.id, + "move_orig_ids": [(6, 0, move.ids)], + } + ) + next_move._action_confirm(merge=False) + next_move._assign_picking() + self.assertEqual(next_move.state, "waiting") + self.assertTrue(next_move.picking_id) + self._simulate_selected_move_line(move_line) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + # Check the resulting data + # We got a new picking as the original one had two moves (and we + # validated only one) + new_picking = move_line.picking_id + self.assertTrue(new_picking != original_picking) + self.assertEqual(move_line.move_id.state, "done") + self.assertEqual(move_line.picking_id.state, "done") + self.assertEqual(original_picking.state, "assigned") + self.assertEqual(next_move.state, "assigned") + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + completion_info = self.service._actions_for("completion.info") + completion_info_popup = completion_info.popup(move_line) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + popup=completion_info_popup, + ) + + def test_set_destination_line_partial_qty(self): + """Scanned destination location with partial qty, but related moves + has to be splitted. + """ + original_picking = self.picking2 + move_line_c = original_picking.move_line_ids.filtered( + lambda m: m.product_id == self.product_c + ) + self.assertEqual(move_line_c.reserved_uom_qty, 10) + self.assertEqual(move_line_c.qty_done, 10) + self._simulate_selected_move_line(move_line_c) + # Scan partial qty (6/10) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_c.id, + "quantity": move_line_c.reserved_uom_qty - 4, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + done_picking = original_picking.backorder_ids + # Check move line data + self.assertEqual(move_line_c.move_id.product_uom_qty, 6) + self.assertEqual(move_line_c.reserved_uom_qty, 0) + self.assertEqual(move_line_c.qty_done, 6) + self.assertEqual(move_line_c.state, "done") + self.assertEqual(original_picking.backorder_ids, done_picking) + self.assertEqual(done_picking.state, "done") + + # the remaining move is put in a backorder + move = done_picking.backorder_ids.move_ids + self.assertEqual(move.picking_id.state, "assigned") + + self.assertEqual(move.state, "assigned") + self.assertEqual(move.product_id, self.product_c) + self.assertEqual(move.product_uom_qty, 4) + self.assertEqual(move.move_line_ids.reserved_uom_qty, 4) + self.assertEqual(move.move_line_ids.qty_done, 4) + # Check the response -> we must first process the backorder + self.assert_response_start_single( + response, + done_picking.backorder_ids, + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + ) + self.assertEqual(move_line_c.move_id.state, "done") + # Scan remaining qty (4/10) + remaining_move_line_c = move.move_line_ids + self._simulate_selected_move_line(remaining_move_line_c) + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": remaining_move_line_c.id, + "quantity": remaining_move_line_c.reserved_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + done_picking2 = remaining_move_line_c.picking_id + # Check move line data + self.assertEqual(remaining_move_line_c.move_id.product_uom_qty, 4) + self.assertEqual(remaining_move_line_c.reserved_uom_qty, 0) + self.assertEqual(remaining_move_line_c.qty_done, 4) + self.assertEqual(remaining_move_line_c.state, "done") + self.assertTrue(done_picking2 != original_picking) + self.assertEqual(done_picking2.state, "done") + # All move lines related to product_c are now done and extracted from + # the initial transfer + all_pickings = original_picking | done_picking | done_picking2 + moves_product_c = all_pickings.move_ids.filtered( + lambda m: m.product_id == self.product_c + ) + moves_product_c_done = all(move.state == "done" for move in moves_product_c) + self.assertTrue(moves_product_c_done) + moves_product_c_qty_done = sum([move.quantity_done for move in moves_product_c]) + self.assertEqual(moves_product_c_qty_done, 10) + # The picking is still not done as product_d hasn't been processed + self.assertEqual(original_picking.state, "assigned") + # Let scan product_d quantity and check picking state + move_line_d = original_picking.move_line_ids.filtered( + lambda m: m.product_id == self.product_d + ) + self._simulate_selected_move_line(move_line_d) + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_d.id, + "quantity": move_line_d.reserved_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + self.assertEqual(move_line_d.move_id.product_uom_qty, 10) + self.assertEqual(move_line_d.reserved_uom_qty, 0) + self.assertEqual(move_line_d.qty_done, 10) + self.assertEqual(move_line_d.state, "done") + self.assertEqual(original_picking.state, "done") + + def test_set_destination_line_partial_qty_with_backorder_policy(self): + """Scanned destination location with partial qty, but related moves + has to be splitted. Since the backorder policy is 'never', the + remaining move line should be removed. + """ + # set the backorder policy to 'never' + + picking = self._create_picking(lines=[(self.product_a, 10)]) + picking.picking_type_id.sudo().create_backorder = "never" + self._update_qty_in_location(picking.location_id, self.product_a, 20) + # Reserve quantities + picking.action_assign() + self._simulate_pickings_selected(picking) + move_line = picking.move_line_ids[0] + self._simulate_selected_move_line(move_line) + # Scan partial qty (6/10) + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty - 4, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + done_picking = picking + # Check move line data + self.assertEqual(move_line.move_id.product_uom_qty, 6) + self.assertEqual(move_line.reserved_uom_qty, 0) + self.assertEqual(move_line.qty_done, 6) + self.assertEqual(move_line.state, "done") + self.assertEqual(done_picking.state, "done") + + # no remaining move should exist + self.assertFalse(done_picking.backorder_ids.move_ids) + + def test_set_destination_lines_partial_qty_with_backorder_policy(self): + """Scanned destination location with partial qty, but related moves + has to be splitted. Since the backorder policy is 'never', the + remaining move line should be removed. + + # multi lines mode + """ + # set the backorder policy to 'never' + + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + picking.picking_type_id.sudo().create_backorder = "never" + self._update_qty_in_location(picking.location_id, self.product_a, 20) + self._update_qty_in_location(picking.location_id, self.product_b, 20) + # Reserve quantities + picking.action_assign() + self._simulate_pickings_selected(picking) + move_line = picking.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_a + ) + self._simulate_selected_move_line(move_line) + # Scan partial qty (6/10) + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty - 4, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + # 2 operations then the done operation is set into a specific picking + first_done_picking = picking.backorder_ids + # Check move line data + self.assertEqual(move_line.move_id.product_uom_qty, 6) + self.assertEqual(move_line.reserved_uom_qty, 0) + self.assertEqual(move_line.qty_done, 6) + self.assertEqual(move_line.state, "done") + self.assertEqual(first_done_picking.state, "done") + + # no remaining move should exist + self.assertFalse(first_done_picking.backorder_ids.move_ids) + + # process the second line + move_line = picking.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_b + ) + self._simulate_selected_move_line(move_line) + # Scan partial qty (6/10) + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty - 4, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + + # the initial picking should be done + # Check move line data + self.assertEqual(move_line.move_id.product_uom_qty, 6) + self.assertEqual(move_line.reserved_uom_qty, 0) + self.assertEqual(move_line.qty_done, 6) + self.assertEqual(move_line.state, "done") + self.assertEqual(picking.state, "done") + + # no remaining move should exist + self.assertEqual(picking.backorder_ids, first_done_picking) + + +class LocationContentTransferSetDestinationXSpecialCase( + LocationContentTransferCommonCase +): + """Tests for endpoint used from scan_destination (special cases) + + * /set_destination_package + * /set_destination_line + + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.move_product_a = cls.picking.move_ids.filtered( + lambda m: m.product_id == cls.product_a + ) + cls.move_product_b = cls.picking.move_ids.filtered( + lambda m: m.product_id == cls.product_b + ) + # Change the initial demand of product_a to get two move lines for + # reserved qties: + # - 10 from the package + # - 5 from the qty without package + cls._fill_stock_for_moves( + cls.move_product_a, in_package=True, location=cls.content_loc + ) + cls.move_product_a.product_uom_qty = 15 + cls._update_qty_in_location( + cls.picking.location_id, + cls.product_a, + 5, + ) + # Put product_b quantities in two different source locations to get + # two stock move lines (6 and 4 to satisfy 10 qties) + cls._update_qty_in_location(cls.picking.location_id, cls.product_b, 6) + cls._update_qty_in_location(cls.content_loc, cls.product_b, 4) + # Reserve quantities + cls.picking.action_assign() + cls._simulate_pickings_selected(cls.picking) + cls.dest_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": cls.shelf1.id, + } + ) + ) + + def test_set_destination_package_split_move(self): + """Scanned destination location valid for a package, but related moves + has to be splitted because it is linked to additional move lines. + """ + original_picking = self.picking + self.assertEqual(len(original_picking.move_ids), 2) + self.assertEqual(len(self.move_product_a.move_line_ids), 2) + package_level = original_picking.package_level_ids[0] + self._simulate_selected_move_line(package_level.move_line_ids) + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + done_picking = package_level.picking_id + # Check the picking data + self.assertEqual(original_picking.backorder_ids, done_picking) + self.assertEqual(package_level.location_dest_id, self.dest_location) + for move_line in package_level.move_line_ids: + self.assertEqual(move_line.location_dest_id, self.dest_location) + moves_product_a = original_picking.move_ids.filtered( + lambda m: m.product_id == self.product_a + ) + self.assertEqual(len(original_picking.move_ids), 2) + self.assertEqual(len(moves_product_a), 1) + for move in moves_product_a: + self.assertEqual(len(move.move_line_ids), 1) + move_lines_wo_pkg = original_picking.move_line_ids_without_package + move_lines_wo_pkg_states = set(move_lines_wo_pkg.mapped("state")) + self.assertEqual(len(move_lines_wo_pkg_states), 1) + self.assertEqual(move_lines_wo_pkg_states.pop(), "assigned") + self.assertEqual(done_picking.package_level_ids.state, "done") + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + ) + + def test_set_destination_line_split_move(self): + """Scanned destination location valid for a move line, but related moves + has to be splitted because it is linked to additional move lines. + """ + original_picking = self.picking + self.assertEqual(len(original_picking.move_ids), 2) + self.assertEqual(len(self.move_product_b.move_line_ids), 2) + move_line = self.move_product_b.move_line_ids.filtered( + lambda ml: ml.reserved_uom_qty == 6 + ) + self._simulate_selected_move_line(move_line) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + done_picking = move_line.picking_id + # Check the picking data + self.assertEqual(original_picking.backorder_ids, done_picking) + self.assertEqual(done_picking.state, "done") + self.assertEqual(original_picking.state, "assigned") + self.assertEqual(move_line.move_id.product_uom_qty, 6) + self.assertEqual(move_line.reserved_uom_qty, 0) + self.assertEqual(move_line.qty_done, 6) + self.assertEqual(move_line.location_dest_id, self.dest_location) + self.assertEqual(len(original_picking.move_ids), 2) + moves_product_b = original_picking.move_ids.filtered( + lambda m: m.product_id == self.product_b + ) + self.assertEqual(len(moves_product_b), 1) + for move in moves_product_b: + self.assertEqual(len(move.move_line_ids), 1) + move_lines_wo_pkg = original_picking.move_line_ids_without_package + move_lines_wo_pkg_states = set(move_lines_wo_pkg.mapped("state")) + self.assertEqual(len(move_lines_wo_pkg_states), 1) + self.assertTrue(all(state == "assigned" for state in move_lines_wo_pkg_states)) + self.assertEqual(move_line.state, "done") + remaining_move = original_picking.move_ids.filtered( + lambda m: move_line.move_id != m and m.product_id == self.product_b + ) + self.assertEqual(remaining_move.state, "assigned") + self.assertEqual(remaining_move.product_uom_qty, 4) + self.assertEqual(remaining_move.move_line_ids.reserved_uom_qty, 4) + self.assertEqual(remaining_move.move_line_ids.qty_done, 4) + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + ) + # Process the other move lines (lines w/o package + package levels) + # to check the picking state + remaining_move_lines = original_picking.move_line_ids_without_package.filtered( + lambda ml: ml.state == "assigned" + ) + for ml in remaining_move_lines: + self._simulate_selected_move_line(ml) + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": ml.id, + "quantity": ml.reserved_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + self.assertEqual(original_picking.state, "assigned") + package_level = original_picking.package_level_ids[0] + self._simulate_selected_move_line(package_level.move_line_ids) + self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + self.assertEqual(original_picking.state, "done") + + +class LocationContentTransferSetDestinationChainSpecialCase( + LocationContentTransferCommonCase +): + """Tests for endpoint used from scan_destination (special cases with + chained pickings) + + * /set_destination_package + * /set_destination_line + + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + # Test split of partial qty when the moves have "move_orig_ids". + # We create a chain of pickings to ensure the proper state is computed + # for the split move. + cls.picking_a = picking_a = cls._create_picking(lines=[(cls.product_c, 10)]) + cls.picking_b = picking_b = cls._create_picking(lines=[(cls.product_c, 10)]) + # connect a and b in a chain of moves + for move_a in picking_a.move_ids: + for move_b in picking_b.move_ids: + if move_a.product_id == move_b.product_id: + move_a.move_dest_ids = move_b + move_b.procure_method = "make_to_order" + + cls.pickings = picking_a | picking_b + cls._fill_stock_for_moves(picking_a.move_ids, location=cls.content_loc) + cls.pickings.action_assign() + + cls.dest_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": cls.shelf1.id, + } + ) + ) + + def test_set_destination_line_partial_qty_with_move_orig_ids(self): + """Scanned destination location with partial qty, but related moves + has to be split and the move has origin moves (with origin moves) + """ + picking_a = self.picking_a + picking_b = self.picking_b + picking_a.move_line_ids.qty_done = 10 + picking_a._action_done() + self.assertEqual(picking_a.state, "done") + self.assertEqual(picking_b.state, "assigned") + self._simulate_pickings_selected(picking_b) + + move_line_c = picking_b.move_line_ids.filtered( + lambda m: m.product_id == self.product_c + ) + + self.assertEqual(move_line_c.reserved_uom_qty, 10) + self.assertEqual(move_line_c.qty_done, 10) + # Scan partial qty (6/10) + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_c.id, + "quantity": move_line_c.reserved_uom_qty - 4, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + # Check move line data + self.assertEqual(move_line_c.move_id.product_uom_qty, 6) + self.assertEqual(move_line_c.reserved_uom_qty, 0) + self.assertEqual(move_line_c.qty_done, 6) + self.assertEqual(move_line_c.state, "done") + # the move has been split + move = move_line_c.picking_id.backorder_ids.move_ids + self.assertNotEqual(move_line_c.move_id, move) + + # Check the move handling the remaining qty + self.assertEqual(move.state, "assigned") + move_line = move.move_line_ids + self.assertEqual(move_line.move_id.product_uom_qty, 4) + self.assertEqual(move_line.reserved_uom_qty, 4) + self.assertEqual(move_line.qty_done, 4) + + def test_set_destination_package_partial_qty_with_move_orig_ids(self): + """Scanned destination location with partial qty, but related moves + has to be split and the move has origin moves + (with package and origin moves) + """ + picking_a = self.picking_a + picking_b = self.picking_b + + # we put 6 in a new package and 4 in another new package + package1 = self.env["stock.quant.package"].create({}) + package2 = self.env["stock.quant.package"].create({}) + line1 = picking_a.move_line_ids + line2 = line1.copy({"reserved_uom_qty": 4, "qty_done": 4}) + line1.with_context(bypass_reservation_update=True).reserved_uom_qty = 6 + line1.qty_done = 6 + line1.result_package_id = package1 + line2.result_package_id = package2 + picking_a._action_done() + self.assertEqual(picking_a.state, "done") + self.assertEqual(picking_b.state, "assigned") + # we have 1 move line per package + self.assertEqual(len(picking_b.move_line_ids), 2) + self._simulate_pickings_selected(picking_b) + + move_line = picking_b.move_line_ids.filtered(lambda m: m.package_id == package1) + move = move_line.move_id + + self.assertEqual(move_line.reserved_uom_qty, 6.0) + self.assertEqual(move_line.qty_done, 6.0) + self._simulate_selected_move_line(move_line) + # Scan partial qty (6/10) + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": 6.0, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + # Check move line data + self.assertEqual(move_line.move_id.product_uom_qty, 6) + self.assertEqual(move_line.reserved_uom_qty, 0) + self.assertEqual(move_line.qty_done, 6) + self.assertEqual(move_line.state, "done") + # the move has been split + self.assertNotEqual(move_line.move_id, move) + + # Check the move handling the remaining qty + self.assertEqual(move.state, "assigned") + move_line = move.move_line_ids + self.assertEqual(move_line.move_id.product_uom_qty, 4) + self.assertEqual(move_line.reserved_uom_qty, 4) + self.assertEqual(move_line.qty_done, 4) + + +class LocationContentTransferSetDestinationNextOperationSpecialCase( + LocationContentTransferCommonCase +): + """Tests for endpoint used from scan_destination to ensure that in + case of partial qty, the next operation is the one for the remaining + qty. + + * /set_destination_line + + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls._update_qty_in_location(cls.picking.location_id, cls.product_a, 20) + cls._update_qty_in_location(cls.picking.location_id, cls.product_b, 20) + # Reserve quantities + cls.picking.action_assign() + cls._simulate_pickings_selected(cls.picking) + cls.dest_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": cls.shelf1.id, + } + ) + ) + + def test_set_destination_lines_partial_qty_next_line(self): + """Scanned destination location with partial qty, the next line to process + should be the one for the remaining qty. + """ + + move_line = self.picking.move_line_ids[0] + self._simulate_selected_move_line(move_line) + # Scan partial qty (6/10) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty - 4, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + # the new qty is in a backorder of the backorder where the done qty has bee + # processed + backorder = self.picking.backorder_ids.backorder_ids + self.assertTrue(backorder) + + self.assert_response_start_single( + response, + backorder, + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + ) + # check that the next operation has the appropriate attributes + move_line = backorder.move_line_ids + self.assertEqual(move_line.reserved_uom_qty, 4) + self.assertEqual(move_line.qty_done, 4) + self.assertEqual(move_line.picking_id.user_id, self.env.user) + # if we process the quantity of the backorder, the next operation should + # be the remaining one of the initial picking + self._simulate_selected_move_line(move_line) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.reserved_uom_qty, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + self.assert_response_start_single( + response, + self.picking, + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + ) diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py new file mode 100644 index 0000000000..687156da1a --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -0,0 +1,748 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_location_content_transfer_base import LocationContentTransferCommonCase + +# pylint: disable=missing-return + + +class LocationContentTransferSingleCase(LocationContentTransferCommonCase): + """Tests for endpoint used from state start_single + + * /scan_package + * /scan_line + * /postpone_package + * /postpone_line + + """ + + # TODO common with set_destination_all? + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.product_d.tracking = "lot" + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_ids, in_package=True, location=cls.content_loc + ) + cls.product_d_lot = cls.env["stock.lot"].create( + {"product_id": cls.product_d.id, "company_id": cls.env.company.id} + ) + cls._fill_stock_for_moves(picking2.move_ids[0], location=cls.content_loc) + cls._fill_stock_for_moves( + picking2.move_ids[1], location=cls.content_loc, in_lot=cls.product_d_lot + ) + cls.pickings.action_assign() + cls._simulate_pickings_selected(cls.pickings) + + def _test_scan_package_ok(self, barcode): + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": barcode, + }, + ) + self.assert_response_scan_destination(response, package_level) + + def test_scan_package_location_not_found(self): + response = self.service.dispatch( + "scan_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": 42, + "barcode": "TEST", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + + def test_scan_package_package_ok(self): + package_level = self.picking1.move_line_ids.package_level_id + self._test_scan_package_ok(package_level.package_id.name) + + def _scan_package_error(self, package_level, scanned, message): + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": scanned, + }, + ) + self.assert_response_start_single(response, self.pickings, message=message) + + def test_scan_package_error_wrong_package(self): + """Wrong package scanned""" + pack = self.env["stock.quant.package"].sudo().create({}) + self._scan_package_error( + self.picking1.move_line_ids.package_level_id, + pack.name, + {"message_type": "error", "body": "Wrong pack."}, + ) + + def test_scan_package_error_wrong_product(self): + """Wrong product scanned""" + product = ( + self.env["product.product"] + .sudo() + .create( + { + "name": "Wrong", + "barcode": "WRONGPRODUCT", + } + ) + ) + self._scan_package_error( + self.picking1.move_line_ids.package_level_id, + product.barcode, + {"message_type": "error", "body": "Wrong product."}, + ) + + def test_scan_package_error_wrong_lot(self): + """Wrong product scanned""" + lot = ( + self.env["stock.lot"] + .sudo() + .create( + { + "name": "WRONGLOT", + "product_id": self.picking1.move_line_ids[0].product_id.id, + "company_id": self.env.company.id, + } + ) + ) + self._scan_package_error( + self.picking1.move_line_ids.package_level_id, + lot.name, + {"message_type": "error", "body": "Wrong lot."}, + ) + + def test_scan_package_barcode_not_found(self): + """Nothing found for the barcode""" + self._scan_package_error( + self.picking1.move_line_ids.package_level_id, + "NO_EXISTING_BARCODE", + {"message_type": "error", "body": "Barcode not found"}, + ) + + def test_scan_package_product_ok(self): + # product_a is in the package and anywhere else so it's + # accepted to check we scanned the correct package + self._test_scan_package_ok(self.product_a.barcode) + + def test_scan_package_product_packaging_ok(self): + # product_a is in the package and anywhere else so it's + # accepted to check we scanned the correct package + self._test_scan_package_ok(self.product_a.packaging_ids[0].barcode) + + def test_scan_package_lot_ok(self): + package_level = self.picking1.move_line_ids.package_level_id + line_product_a = package_level.move_line_ids[0] + self.product_a.tracking = "lot" + line_product_a.lot_id = self.env["stock.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + # lot of product_a is in the package and anywhere else so it's + # accepted to check we scanned the correct package + self._test_scan_package_ok(line_product_a.lot_id.name) + + def _test_scan_package_nok(self, pickings, barcode, message): + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": barcode, + }, + ) + self.assert_response_start_single(response, pickings, message=message) + + def test_scan_package_product_nok_different_package(self): + # add another picking with a package with product a, + # if we scan product A, we can't know for which package it is + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking.move_ids, in_package=True, location=self.content_loc + ) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_product_nok_different_line(self): + # add another picking with a raw line with product a, + # if we scan product A, we can't know which line/package we want + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_ids, location=self.content_loc) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_product_nok_product_tracked(self): + # we scan product_a's barcode but it's tracked by lot + self.product_a.tracking = "lot" + self._test_scan_package_nok( + self.pickings, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_lot_nok_different_package(self): + # add another picking with a package with the lot used in our package, + # if we scan the lot, we can't know for which package it is + package_level = self.picking1.move_line_ids.package_level_id + line_product_a = package_level.move_line_ids[0] + self.product_a.tracking = "lot" + line_product_a.lot_id = lot = self.env["stock.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking.move_ids, in_package=True, in_lot=lot, location=self.content_loc + ) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_lot_nok_different_line(self): + # add another picking with a raw line with a lot used in our package, + # if we scan the lot, we can't know which line/package we want + package_level = self.picking1.move_line_ids.package_level_id + line_product_a = package_level.move_line_ids[0] + self.product_a.tracking = "lot" + line_product_a.lot_id = lot = self.env["stock.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking.move_ids, in_lot=lot, location=self.content_loc + ) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_package_level_not_exists(self): + package_level = self.picking1.move_line_ids.package_level_id + package_level_id = package_level.id + package_level.unlink() + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level_id, + "barcode": self.product_a.barcode, + }, + ) + self.assert_response_start_single( + response, self.pickings, message=self.service.msg_store.record_not_found() + ) + + def _test_scan_line_ok(self, move_line, barcode): + response = self.service.dispatch( + "scan_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "barcode": barcode, + }, + ) + self.assert_response_scan_destination(response, move_line) + + def test_scan_line_package_ok(self): + move_line = self.picking2.move_line_ids[0] + package = move_line.package_id = self.env["stock.quant.package"].create({}) + self._test_scan_line_ok(move_line, package.name) + + def test_scan_line_product_ok(self): + move_line = self.picking2.move_line_ids[0] + # check we selected the good line + self.assertEqual(move_line.product_id, self.product_c) + self._test_scan_line_ok(move_line, self.product_c.barcode) + + def test_scan_line_product_packaging_ok(self): + move_line = self.picking2.move_line_ids[0] + # check we selected the good line + self.assertEqual(move_line.product_id, self.product_c) + self._test_scan_line_ok(move_line, self.product_c.packaging_ids[0].barcode) + + def test_scan_line_lot_ok(self): + move_line = self.picking2.move_line_ids[1] + # check we selected the good line (the one with a lot) + self.assertEqual(move_line.product_id, self.product_d) + self._test_scan_line_ok(move_line, self.product_d_lot.name) + + def _test_scan_line_nok(self, pickings, move_line_id, barcode, message): + response = self.service.dispatch( + "scan_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_id, + "barcode": barcode, + }, + ) + self.assert_response_start_single(response, pickings, message=message) + + def test_scan_line_product_nok_product_tracked(self): + # we scan product_d's barcode but it's tracked by lot + move_line = self.picking2.move_line_ids[1] + # check we selected the good line (the one with a lot) + self.assertEqual(move_line.product_id, self.product_d) + self._test_scan_line_nok( + self.pickings, + move_line.id, + self.product_d.barcode, + self.service.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + + def test_scan_line_error_wrong_package(self): + """Wrong package scanned""" + move_line = self.picking2.move_line_ids[0] + pack = self.env["stock.quant.package"].sudo().create({}) + self._test_scan_line_nok( + self.pickings, + move_line.id, + pack.name, + {"message_type": "error", "body": "Wrong pack."}, + ) + + def test_scan_line_error_wrong_product(self): + """Wrong product scanned""" + move_line = self.picking2.move_line_ids[0] + product = ( + self.env["product.product"] + .sudo() + .create( + { + "name": "Wrong", + "barcode": "WRONGPRODUCT", + } + ) + ) + self._test_scan_line_nok( + self.pickings, + move_line.id, + product.barcode, + {"message_type": "error", "body": "Wrong product."}, + ) + + def test_scan_line_error_wrong_lot(self): + """Wrong product scanned""" + move_line = self.picking2.move_line_ids[0] + lot = ( + self.env["stock.lot"] + .sudo() + .create( + { + "name": "WRONGLOT", + "product_id": move_line.product_id.id, + "company_id": self.env.company.id, + } + ) + ) + self._test_scan_line_nok( + self.pickings, + move_line.id, + lot.name, + {"message_type": "error", "body": "Wrong lot."}, + ) + + def test_scan_line_barcode_not_found(self): + move_line = self.picking2.move_line_ids[0] + self._test_scan_line_nok( + self.pickings, + move_line.id, + "NOT_FOUND", + self.service.msg_store.barcode_not_found(), + ) + + def test_scan_line_move_line_not_exists(self): + move_line = self.picking2.move_line_ids[0] + move_line_id = move_line.id + move_line.unlink() + self._test_scan_line_nok( + self.pickings, + move_line_id, + "NOT_FOUND", + self.service.msg_store.record_not_found(), + ) + + def test_postpone_package_wrong_parameters(self): + """Wrong 'location_id' and 'package_level_id' parameters, redirect the + user to the 'start' screen. + """ + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "postpone_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": package_level.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "postpone_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) + + def test_postpone_package_ok(self): + package_level = self.picking1.move_line_ids.package_level_id + previous_priority = package_level.shopfloor_priority + self.assertFalse(package_level.shopfloor_postponed) + response = self.service.dispatch( + "postpone_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + self.assertTrue(package_level.shopfloor_postponed) + self.assertEqual(package_level.shopfloor_priority, previous_priority + 1) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) + + def test_postpone_sorter(self): + move_line = self.picking2.move_line_ids[0] + move_lines = self.service._find_transfer_move_lines(self.content_loc) + pickings = move_lines.mapped("picking_id") + sorter = self.service._actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + content_sorted1 = list(sorter) + self.service.dispatch( + "postpone_line", + params={"location_id": self.content_loc.id, "move_line_id": move_line.id}, + ) + sorter.sort() + content_sorted2 = list(sorter) + self.assertTrue(content_sorted1 != content_sorted2) + + def test_postpone_line_wrong_parameters(self): + """Wrong 'location_id' and 'move_line_id' parameters, redirect the + user to the 'start' screen. + """ + move_line = self.picking2.move_line_ids[0] + response = self.service.dispatch( + "postpone_line", + params={ + "location_id": 1234567890, # Doesn't exist + "move_line_id": move_line.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "postpone_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) + + def test_postpone_line_ok(self): + move_line = self.picking2.move_line_ids[0] + previous_priority = move_line.shopfloor_priority + self.assertFalse(move_line.shopfloor_postponed) + response = self.service.dispatch( + "postpone_line", + params={"location_id": self.content_loc.id, "move_line_id": move_line.id}, + ) + self.assertTrue(move_line.shopfloor_postponed) + self.assertEqual(move_line.shopfloor_priority, previous_priority + 1) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) + + def test_stock_out_package_wrong_parameters(self): + """Wrong 'location_id' and 'package_level_id' parameters, redirect the + user to the 'start' screen. + """ + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": package_level.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) + + def test_stock_out_package_ok(self): + """Declare a stock out on a package_level.""" + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) + + def test_stock_out_line_wrong_parameters(self): + """Wrong 'location_id' and 'move_line_id' parameters, redirect the + user to the 'start' screen. + """ + move_line = self.picking2.move_line_ids[0] + response = self.service.dispatch( + "stock_out_line", + params={ + "location_id": 1234567890, # Doesn't exist + "move_line_id": move_line.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "stock_out_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) + + def test_dismiss_package_level_ok(self): + """Open a package level""" + package_level = self.picking1.move_line_ids.package_level_id + move_lines = package_level.move_line_ids + response = self.service.dispatch( + "dismiss_package_level", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + self.assertFalse(package_level.exists()) + self.assertFalse(move_lines.result_package_id) + self.assertFalse(move_lines.package_level_id) + self.assertEqual(move_lines.mapped("shopfloor_priority"), [1, 1]) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.package_open(), + ) + + def test_dismiss_package_level_error_no_package_level(self): + """Open a package level, send unknown package level id""" + response = self.service.dispatch( + "dismiss_package_level", + params={"location_id": self.content_loc.id, "package_level_id": 0}, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.record_not_found(), + ) + + def test_dismiss_package_level_error_no_location(self): + """Open a package level, send unknown location id""" + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "dismiss_package_level", + params={"location_id": 0, "package_level_id": package_level.id}, + ) + self.assert_response_start( + response, + message=self.service.msg_store.record_not_found(), + ) + + +class LocationContentTransferSingleSpecialCase(LocationContentTransferCommonCase): + """Tests for endpoint used from state start_single (special cases) + + * /stock_out_package + * /stock_out_line + + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a | cls.product_b + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.move_product_a = cls.picking.move_ids.filtered( + lambda m: m.product_id == cls.product_a + ) + cls.move_product_b = cls.picking.move_ids.filtered( + lambda m: m.product_id == cls.product_b + ) + # Change the initial demand of product_a to get two move lines for + # reserved qties: + # - 10 from the package + # - 5 from the qty without package + cls._fill_stock_for_moves( + cls.move_product_a, in_package=True, location=cls.content_loc + ) + cls.move_product_a.product_uom_qty = 15 + cls._update_qty_in_location( + cls.content_loc, + cls.product_a, + 5, + ) + # Put product_b quantities in two different source locations to get + # two stock move lines (6 and 4 to satisfy 10 qties) + cls._update_qty_in_location(cls.picking.location_id, cls.product_b, 6) + cls._update_qty_in_location(cls.content_loc, cls.product_b, 4) + # Reserve quantities + cls.picking.action_assign() + cls._simulate_pickings_selected(cls.picking) + + def test_stock_out_package_split_move(self): + """Declare a stock out on a package_level related to moves containing + other unrelated move lines. + """ + package_level = self.picking.move_line_ids.package_level_id + self.assertEqual(self.product_a.qty_available, 15) + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + # Check the picking data + self.assertFalse(package_level.exists()) + moves_product_a = self.picking.move_ids.filtered( + lambda m: m.product_id == self.product_a + ) + self.assertEqual(len(moves_product_a), 2) + move_product_a = moves_product_a.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(len(move_product_a), 1) + self.assertEqual(move_product_a.state, "assigned") + self.assertEqual(len(move_product_a.move_line_ids), 1) + self.assertEqual(self.product_a.qty_available, 5) + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) + + def test_stock_out_line_split_move(self): + """Declare a stock out on a move line related to moves containing + other move lines. + """ + self.assertEqual(len(self.picking.move_ids), 2) + self.assertEqual(len(self.move_product_b.move_line_ids), 2) + move_line = self.move_product_b.move_line_ids.filtered( + lambda ml: ml.reserved_uom_qty == 4 # 4/10 to stock out + ) + self.assertEqual(self.product_b.qty_available, 10) + response = self.service.dispatch( + "stock_out_line", + params={"location_id": self.content_loc.id, "move_line_id": move_line.id}, + ) + # Check the picking data + self.assertFalse(move_line.exists()) + moves_product_b = self.picking.move_ids.filtered( + lambda m: m.product_id == self.product_b + ) + self.assertEqual(len(moves_product_b), 2) + move_product_b = moves_product_b.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(len(move_product_b), 1) + self.assertEqual(move_product_b.state, "assigned") + self.assertEqual(len(move_product_b.move_line_ids), 1) + + self.assertEqual(self.product_b.qty_available, 6) + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + ) diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py new file mode 100644 index 0000000000..d51597ee98 --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -0,0 +1,359 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_location_content_transfer_base import LocationContentTransferCommonCase + +# pylint: disable=missing-return + + +class TestLocationContentTransferStart(LocationContentTransferCommonCase): + """Tests for start state and recover + + Endpoints: + + * /start_or_recover + * /scan_location + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + + cls.content_loc2 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Content Location 2", + "barcode": "Content2", + "location_id": cls.picking_type.default_location_src_id.id, + } + ) + ) + cls._fill_stock_for_moves( + picking1.move_ids, in_package=True, location=cls.content_loc + ) + cls._fill_stock_for_moves(picking2.move_ids[0], location=cls.content_loc) + cls._fill_stock_for_moves(picking2.move_ids[1], location=cls.content_loc2) + cls.pickings.action_assign() + cls.move_lines = cls.pickings.move_line_ids + + def test_start_fresh(self): + """Start a fresh session when there is no transfer to recover""" + response = self.service.dispatch("start_or_recover", params={}) + self.assert_response(response, next_state="scan_location") + + def test_start_recover_destination_all(self): + """Recover transfers, all move lines have the same destination""" + self._simulate_pickings_selected(self.picking1) + # all lines go to the same destination (shelf1) + self.assertEqual(len(self.picking1.mapped("move_line_ids.location_dest_id")), 1) + + response = self.service.dispatch("start_or_recover", params={}) + self.assert_response_scan_destination_all( + response, + self.picking1, + message=self.service.msg_store.recovered_previous_session(), + ) + + def test_start_recover_destination_single(self): + """Recover transfers, at least one move line has a different destination""" + self._simulate_pickings_selected(self.pickings) + self.picking1.package_level_ids.location_dest_id = self.shelf2 + # we have different destinations + self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 2) + response = self.service.dispatch("start_or_recover", params={}) + self.assert_response_start_single( + response, + self.pickings, + message=self.service.msg_store.recovered_previous_session(), + ) + + def test_scan_location_not_found(self): + """Scan a location with content to transfer, barcode not found""" + response = self.service.dispatch( + "scan_location", params={"barcode": "NOT_FOUND"} + ) + self.assert_response_start( + response, message=self.service.msg_store.barcode_not_found() + ) + + def test_scan_location_find_content_destination_all(self): + """Scan a location with content to transfer, all dest. identical""" + # all lines go to the same destination (shelf1) + self.assertEqual(len(self.move_lines.location_dest_id), 1) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + processed_move_lines = self.move_lines.filtered( + lambda line: line.location_id == self.content_loc + ) + processed_pickings = processed_move_lines.picking_id + self.assertTrue(processed_pickings != self.pickings) + self.assert_response_scan_destination_all(response, processed_pickings) + self.assertRecordValues( + processed_pickings, [{"user_id": self.env.uid}, {"user_id": self.env.uid}] + ) + self.assertRecordValues( + processed_move_lines, + [ + {"qty_done": 10.0}, + {"qty_done": 10.0}, + {"qty_done": 10.0}, + ], + ) + self.assertRecordValues( + processed_pickings.package_level_ids, [{"is_done": True}] + ) + + def test_scan_location_find_content_destination_single(self): + """Scan a location with content to transfer, different destinations""" + self.picking1.package_level_ids.location_dest_id = self.shelf2 + # we have different destinations + self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 2) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + processed_move_lines = self.move_lines.filtered( + lambda line: line.location_id == self.content_loc + ) + processed_pickings = processed_move_lines.picking_id + self.assert_response_start_single(response, processed_pickings) + self.assertRecordValues( + processed_pickings, [{"user_id": self.env.uid}, {"user_id": self.env.uid}] + ) + self.assertRecordValues( + processed_move_lines, + [ + {"qty_done": 10.0}, + {"qty_done": 10.0}, + {"qty_done": 10.0}, + ], + ) + self.assertRecordValues( + processed_pickings.package_level_ids, [{"is_done": True}] + ) + + def test_scan_location_different_picking_type(self): + """Content has different picking types, can't move""" + picking_other_type = self._create_picking( + picking_type=self.wh.pick_type_id, lines=[(self.product_a, 10)] + ) + self._fill_stock_for_moves( + picking_other_type.move_ids, location=self.content_loc + ) + picking_other_type.action_assign() + + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start( + response, + message={ + "message_type": "error", + "body": "This location content can't be moved at once.", + }, + ) + + +class LocationContentTransferStartSpecialCase(LocationContentTransferCommonCase): + """Tests for start state and recover (special cases without setup) + + Endpoints: + + * /start_or_recover + * /scan_location + """ + + def test_scan_location_wrong_picking_type_error(self): + """Content has different picking type than menu""" + picking = self._create_picking( + picking_type=self.wh.pick_type_id, + lines=[(self.product_a, 10), (self.product_b, 10)], + ) + self._fill_stock_for_moves( + picking.move_ids, in_package=True, location=self.content_loc + ) + picking.action_assign() + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start( + response, + message={ + "message_type": "error", + "body": "You cannot move this using this menu.", + }, + ) + + def test_scan_location_wrong_picking_type_allow_unreserve_ok(self): + """Content has different picking type than menu, option to unreserve + + The content must be unreserved, new moves created and the previous + content re-reserved. + """ + self.menu.sudo().allow_unreserve_other_moves = True + + picking = self._create_picking( + picking_type=self.wh.pick_type_id, + lines=[(self.product_a, 10), (self.product_b, 10)], + ) + self._fill_stock_for_moves( + picking.move_ids, in_package=True, location=self.content_loc + ) + picking.action_assign() + # place goods in shelf1 to ensure the original picking can take goods here + other_pack_a = self.env["stock.quant.package"].create({}) + other_pack_b = self.env["stock.quant.package"].create({}) + self._update_qty_in_location( + self.shelf1, self.product_a, 10, package=other_pack_a + ) + self._update_qty_in_location( + self.shelf1, self.product_b, 10, package=other_pack_b + ) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + new_picking = self.env["stock.picking"].search( + [("picking_type_id", "=", self.picking_type.id)] + ) + self.assertEqual(len(new_picking), 1) + self.assert_response_scan_destination_all(response, new_picking) + self.assertRecordValues(new_picking, [{"user_id": self.env.uid}]) + self.assertRecordValues( + new_picking.move_line_ids, + [{"qty_done": 10.0}, {"qty_done": 10.0}], + ) + self.assertRecordValues(new_picking.package_level_ids, [{"is_done": True}]) + + # the original picking must be reserved again, should have taken the goods + # of shelf1 + self.assertRecordValues( + picking.move_line_ids, + [ + { + "qty_done": 0.0, + "location_id": self.shelf1.id, + "package_id": other_pack_a.id, + }, + { + "qty_done": 0.0, + "location_id": self.shelf1.id, + "package_id": other_pack_b.id, + }, + ], + ) + + def test_scan_location_wrong_picking_type_allow_unreserve_empty(self): + """Content has different picking type than menu, option to unreserve + + There is no move line of another picking type to unreserve. + """ + self.menu.sudo().allow_unreserve_other_moves = True + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_empty(self.content_loc), + ) + + def test_scan_location_wrong_picking_type_allow_unreserve_error(self): + """Content has different picking type than menu, option to unreserve + + If quantity has been partially picked on the existing transfer, prevent + to unreserve them. + """ + self.menu.sudo().allow_unreserve_other_moves = True + + picking = self._create_picking( + picking_type=self.wh.pick_type_id, + lines=[(self.product_a, 10), (self.product_b, 10)], + ) + self._fill_stock_for_moves( + picking.move_ids, in_package=True, location=self.content_loc + ) + picking.action_assign() + # a user picked qty + picking.move_line_ids[0].qty_done = 10 + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start( + response, + message=self.service.msg_store.picking_already_started_in_location(picking), + ) + # check that the original moves are still assigned + self.assertRecordValues( + picking.move_ids, [{"state": "assigned"}, {"state": "assigned"}] + ) + + def test_scan_location_create_moves(self): + """The scanned location has no move lines but has some quants to move.""" + picking_type = self.menu.picking_type_ids + # product_a alone + self.env["stock.quant"]._update_available_quantity( + self.product_a, + self.content_loc, + 10, + ) + # product_b in a package + package = self.env["stock.quant.package"].create({}) + self.env["stock.quant"]._update_available_quantity( + self.product_b, self.content_loc, 10, package_id=package + ) + # product_c & product_d in a package + package2 = self.env["stock.quant.package"].create({}) + self.env["stock.quant"]._update_available_quantity( + self.product_c, self.content_loc, 5, package_id=package2 + ) + self.env["stock.quant"]._update_available_quantity( + self.product_d, self.content_loc, 5, package_id=package2 + ) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + picking = self.env["stock.picking"].search( + [("picking_type_id", "=", picking_type.id)] + ) + self.assertEqual(len(picking), 1) + self.assert_response_scan_destination_all(response, picking) + move_line_id = response["data"]["scan_destination_all"]["move_lines"][0]["id"] + package_levels = response["data"]["scan_destination_all"]["package_levels"] + self.assertIn(move_line_id, picking.move_line_ids.ids) + self.assertEqual(package_levels[0]["id"], picking.package_level_ids[0].id) + self.assertEqual(package_levels[0]["package_src"]["id"], package.id) + self.assertEqual(package_levels[1]["id"], picking.package_level_ids[1].id) + self.assertEqual(package_levels[1]["package_src"]["id"], package2.id) + # product_a in a move line without package + self.assertEqual( + picking.move_line_ids_without_package.mapped("product_id"), self.product_a + ) + # all other products are in package levels + self.assertEqual( + picking.package_level_ids.mapped("package_id.quant_ids.product_id"), + self.product_b | self.product_c | self.product_d, + ) + # all products are in move lines + self.assertEqual( + picking.move_line_ids.mapped("product_id"), + self.product_a | self.product_b | self.product_c | self.product_d, + ) + self.assertEqual(picking.state, "assigned") diff --git a/shopfloor/tests/test_menu_base.py b/shopfloor/tests/test_menu_base.py new file mode 100644 index 0000000000..78100001b5 --- /dev/null +++ b/shopfloor/tests/test_menu_base.py @@ -0,0 +1,261 @@ +# 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_base.tests.common_misc import MenuTestMixin + +from .common import CommonCase + +# pylint: disable=missing-return + + +class CommonMenuCase(CommonCase, MenuTestMixin): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + ref = cls.env.ref + profile1 = ref("shopfloor_base.profile_demo_1") + cls.wms_profile = ref("shopfloor.profile_demo_1") + cls.wms_profile2 = ref("shopfloor.profile_demo_2") + menu_xid_pref = "shopfloor.shopfloor_menu_demo_" + cls.menu_items = ( + ref(menu_xid_pref + "single_pallet_transfer") + | ref(menu_xid_pref + "zone_picking") + | ref(menu_xid_pref + "cluster_picking") + | ref(menu_xid_pref + "checkout") + | ref(menu_xid_pref + "delivery") + | ref(menu_xid_pref + "location_content_transfer") + ) + # Isolate menu items + cls.env["shopfloor.menu"].search( + [("id", "not in", cls.menu_items.ids)] + ).sudo().write({"profile_id": profile1.id}) + + def _data_for_menu_item(self, menu, **kw): + data = super()._data_for_menu_item(menu, **kw) + if menu.picking_type_ids: + data.update( + { + "picking_types": [ + {"id": picking_type.id, "name": picking_type.name} + for picking_type in menu.picking_type_ids + ], + } + ) + expected_counters = kw.get("expected_counters") or {} + counters = expected_counters.get( + menu.id, + { + "lines_count": 0, + "picking_count": 0, + "priority_lines_count": 0, + "priority_picking_count": 0, + }, + ) + data.update(counters) + return data + + +class MenuCountersCommonCase(CommonMenuCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu1 = cls.env.ref("shopfloor.shopfloor_menu_demo_zone_picking") + cls.menu2 = cls.env.ref("shopfloor.shopfloor_menu_demo_cluster_picking") + cls.menu1_picking_type = cls.menu1.picking_type_ids[0] + cls.menu2_picking_type = cls.menu2.picking_type_ids[0] + cls.wms_profile = cls.env.ref("shopfloor.profile_demo_1") + cls.wms_profile2 = cls.env.ref("shopfloor.profile_demo_2") + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + # Setup some data to simulate good amount of operations + # to be worked on w/ the app which will be reflected in the menu counters. + cls.packing_location.sudo().active = True + # We want to limit the tests to a dedicated location in Stock/ to not + # be bothered with pickings brought by demo data + cls.zone_location1 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone location 1", + "location_id": cls.stock_location.id, + "barcode": "ZONE_LOCATION_1", + } + ) + ) + cls.zone_location2 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone location 2", + "location_id": cls.stock_location.id, + "barcode": "ZONE_LOCATION_2", + } + ) + ) + # Set default location for our picking types + cls.menu1_picking_type.sudo().default_location_src_id = cls.zone_location1 + cls.menu2_picking_type.sudo().default_location_src_id = cls.zone_location2 + cls.zone_sublocation1 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 1", + "location_id": cls.zone_location1.id, + "barcode": "ZONE_SUBLOCATION_1", + } + ) + ) + cls.zone_sublocation2 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 2", + "location_id": cls.zone_location2.id, + "barcode": "ZONE_SUBLOCATION_2", + } + ) + ) + cls.zone_sublocation3 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 3", + "location_id": cls.zone_location2.id, + "barcode": "ZONE_SUBLOCATION_3", + } + ) + ) + cls.zone_sublocation4 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 4", + "location_id": cls.zone_location2.id, + "barcode": "ZONE_SUBLOCATION_4", + } + ) + ) + cls.product_e = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product E", + "type": "product", + "default_code": "E", + "barcode": "E", + "weight": 3, + } + ) + ) + cls.product_f = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product F", + "type": "product", + "default_code": "F", + "barcode": "F", + "weight": 3, + } + ) + ) + cls.product_g = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product G", + "type": "product", + "default_code": "G", + "barcode": "G", + "weight": 3, + } + ) + ) + cls.product_h = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product H", + "type": "product", + "default_code": "H", + "barcode": "H", + "weight": 3, + } + ) + ) + products = ( + cls.product_a + + cls.product_b + + cls.product_c + + cls.product_d + + cls.product_e + + cls.product_f + + cls.product_g + + cls.product_h + ) + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + picking_type=cls.menu1_picking_type, lines=[(cls.product_a, 10)] + ) + picking1.priority = "0" + cls._fill_stock_for_moves( + picking1.move_ids, in_package=True, location=cls.zone_sublocation1 + ) + + cls.picking2 = picking2 = cls._create_picking( + picking_type=cls.menu1_picking_type, + lines=[(cls.product_b, 10), (cls.product_c, 10)], + ) + picking2.priority = "1" + cls._fill_stock_for_moves( + picking2.move_ids, in_lot=True, location=cls.zone_sublocation2 + ) + + cls.picking3 = picking3 = cls._create_picking( + picking_type=cls.menu1_picking_type, lines=[(cls.product_d, 10)] + ) + picking3.priority = "0" + cls._fill_stock_for_moves(picking3.move_ids, location=cls.zone_sublocation1) + + cls.picking4 = picking4 = cls._create_picking( + picking_type=cls.menu2_picking_type, lines=[(cls.product_e, 10)] + ) + cls._update_qty_in_location(cls.zone_sublocation3, cls.product_e, 6) + cls._update_qty_in_location(cls.zone_sublocation4, cls.product_e, 4) + + cls.picking5 = picking5 = cls._create_picking( + picking_type=cls.menu2_picking_type, + lines=[(cls.product_b, 10), (cls.product_f, 10)], + ) + cls._fill_stock_for_moves( + picking5.move_ids, in_package=True, location=cls.zone_sublocation2 + ) + cls.picking6 = picking6 = cls._create_picking( + picking_type=cls.menu2_picking_type, + lines=[(cls.product_g, 6), (cls.product_h, 6)], + ) + cls._update_qty_in_location(cls.zone_sublocation2, cls.product_g, 6) + cls._update_qty_in_location(cls.zone_sublocation2, cls.product_h, 3) + + cls.pickings = picking1 | picking2 | picking3 | picking4 | picking5 | picking6 + cls.pickings.action_assign() diff --git a/shopfloor/tests/test_menu_counters.py b/shopfloor/tests/test_menu_counters.py new file mode 100644 index 0000000000..974becbdd4 --- /dev/null +++ b/shopfloor/tests/test_menu_counters.py @@ -0,0 +1,61 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_menu_base import MenuCountersCommonCase + + +class TestMenuCountersCommonCase(MenuCountersCommonCase): + def test_menu_search_profile1(self): + expected_counters = { + self.menu1.id: { + "lines_count": 2, + "picking_count": 2, + "priority_lines_count": 0, + "priority_picking_count": 0, + }, + self.menu2.id: { + "lines_count": 6, + "picking_count": 3, + "priority_lines_count": 0, + "priority_picking_count": 0, + }, + } + expected_menu_items = ( + self.env["shopfloor.menu"] + .sudo() + .search([("profile_id", "=", self.wms_profile.id)]) + ) + service = self.get_service("menu", profile=self.wms_profile) + response = service.dispatch("search") + self._assert_menu_response( + response, + expected_menu_items.sorted("sequence"), + expected_counters=expected_counters, + ) + + def test_menu_search_profile2(self): + expected_counters = { + self.menu1.id: { + "lines_count": 2, + "picking_count": 2, + "priority_lines_count": 0, + "priority_picking_count": 0, + }, + self.menu2.id: { + "lines_count": 6, + "picking_count": 3, + "priority_lines_count": 0, + "priority_picking_count": 0, + }, + } + expected_menu_items = ( + self.env["shopfloor.menu"] + .sudo() + .search([("profile_id", "=", self.wms_profile2.id)]) + ) + service = self.get_service("menu", profile=self.wms_profile2) + response = service.dispatch("search") + self._assert_menu_response( + response, + expected_menu_items.sorted("sequence"), + expected_counters=expected_counters, + ) diff --git a/shopfloor/tests/test_misc.py b/shopfloor/tests/test_misc.py new file mode 100644 index 0000000000..55853ce8e0 --- /dev/null +++ b/shopfloor/tests/test_misc.py @@ -0,0 +1,25 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import exceptions +from odoo.tests.common import TransactionCase + +# pylint: disable=missing-return + + +class MiscTestCase(TransactionCase): + tracking_disable = True + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict(cls.env.context, tracking_disable=cls.tracking_disable) + ) + + def test_package_name_unique(self): + create = self.env["stock.quant.package"].create + create({"name": "GOOD_NAME"}) + with self.assertRaises(exceptions.UserError) as exc: + create({"name": "GOOD_NAME"}) + self.assertEqual(exc.exception.args[0], "Package name must be unique!") diff --git a/shopfloor/tests/test_move_action_assign.py b/shopfloor/tests/test_move_action_assign.py new file mode 100644 index 0000000000..cae4cd9d59 --- /dev/null +++ b/shopfloor/tests/test_move_action_assign.py @@ -0,0 +1,87 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .common import CommonCase + +# pylint: disable=missing-return + + +class TestStockMoveActionAssign(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.wh = cls.env.ref("stock.warehouse0") + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.wh.sudo().delivery_steps = "pick_pack_ship" + + def test_action_assign_package_level(self): + """calling _action_assign on move does not erase lines' "result_package_id" + + At the end of the method ``StockMove._action_assign()``, the method + ``StockPicking._check_entire_pack()`` is called. This method compares + the move lines with the quants of their source package, and if the entire + package is moved at once in the same transfer, a ``stock.package_level`` is + created. On creation of a ``stock.package_level``, the result package of + the move lines is directly updated with the entire package. + + This is good on the first assign of the move, but when we call assign for + the second time on a move, for instance because it was made partially available + and we want to assign the remaining, it can override the result package we + selected before. + + An override of ``StockPicking._check_move_lines_map_quant_package()`` ensures + that we ignore: + + * picked lines (qty_done > 0) + * lines with a different result package already + """ + package = self.env["stock.quant.package"].create({"name": "Src Pack"}) + dest_package1 = self.env["stock.quant.package"].create({"name": "Dest Pack1"}) + + picking = self._create_picking( + picking_type=self.wh.pick_type_id, lines=[(self.product_a, 50)] + ) + self._update_qty_in_location( + picking.location_id, self.product_a, 20, package=package + ) + picking.action_assign() + + self.assertEqual(picking.state, "assigned") + self.assertEqual(picking.package_level_ids.package_id, package) + + move = picking.move_ids + line = move.move_line_ids + + # we are no longer moving the entire package + line.qty_done = 20 + line.result_package_id = dest_package1 + + # create remaining quantity + new_package = self.env["stock.quant.package"].create({"name": "New Pack"}) + self._update_qty_in_location( + picking.location_id, self.product_a, 30, package=new_package + ) + + move._action_assign() + new_line = move.move_line_ids - line + + # At the end of _action_assign(), StockPicking._check_entire_pack() is + # called, which, by default, look the sum of the move lines qties, and + # if they match a package, it: + # + # * creates a package level + # * updates all the move lines result package with the package, + # including the 'done' lines + # + # These checks ensure that we prevent this to happen if we already set + # a result package. + self.assertRecordValues( + line + new_line, + [ + {"qty_done": 20, "result_package_id": dest_package1.id}, + {"qty_done": 0, "result_package_id": new_package.id}, + ], + ) diff --git a/shopfloor/tests/test_openapi.py b/shopfloor/tests/test_openapi.py new file mode 100644 index 0000000000..aa755dd73f --- /dev/null +++ b/shopfloor/tests/test_openapi.py @@ -0,0 +1,21 @@ +# 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_base.tests.common_misc import OpenAPITestMixin + +from .common import CommonCase + +# pylint: disable=missing-return + + +class TestOpenAPICommonCase(CommonCase, OpenAPITestMixin): + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + # we don't really care about which menu and profile we use + # to read the OpenAPI specs + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_demo_delivery") + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") + + def test_openapi(self): + self._test_openapi(menu=self.menu, profile=self.profile) diff --git a/shopfloor/tests/test_picking_form.py b/shopfloor/tests/test_picking_form.py new file mode 100644 index 0000000000..9dfeb28ea9 --- /dev/null +++ b/shopfloor/tests/test_picking_form.py @@ -0,0 +1,62 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .common import CommonCase + +# pylint: disable=missing-return + + +class PickingFormCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_demo_checkout") + cls.profile = cls.env.ref("shopfloor.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = cls._create_picking(lines=[(cls.product_a, 10)]) + + def setUp(self): + super().setUp() + self.service = self.get_service("form_edit_stock_picking", profile=self.profile) + + def test_picking_form_get(self): + available_carriers = self.service._get_available_carriers(self.picking) + response = self.service.dispatch("get", self.picking.id) + self.assert_response( + response, + data={ + "record": self.data_detail.picking_detail(self.picking), + "form": { + "carrier_id": { + "value": self.picking.carrier_id.id, + "select_options": available_carriers.jsonify(["id", "name"]), + } + }, + }, + ) + + def test_picking_form_update(self): + available_carriers = self.service._get_available_carriers(self.picking) + self.picking.carrier_id = available_carriers[0] + params = {"carrier_id": available_carriers[1].id} + response = self.service.dispatch("update", self.picking.id, params=params) + self.assert_response( + response, + data={ + "record": self.data_detail.picking_detail(self.picking), + "form": { + "carrier_id": { + "value": self.picking.carrier_id.id, + "select_options": available_carriers.jsonify(["id", "name"]), + } + }, + }, + message=self.service._msg_record_updated(self.picking), + ) + self.assertRecordValues( + self.picking, [{"carrier_id": available_carriers[1].id}] + ) diff --git a/shopfloor/tests/test_scan_anything.py b/shopfloor/tests/test_scan_anything.py new file mode 100644 index 0000000000..934881f582 --- /dev/null +++ b/shopfloor/tests/test_scan_anything.py @@ -0,0 +1,49 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.shopfloor_base.tests.common_misc import ScanAnythingTestMixin + +from .test_actions_data_base import ActionsDataDetailCaseBase + + +class ScanAnythingCase(ActionsDataDetailCaseBase, ScanAnythingTestMixin): + def test_scan_product(self): + record = self.product_b + record.default_code = "PROD-B-code" + record.barcode = "PROD-B" + rec_type = "product" + identifier = record.barcode + data = self.data_detail.product_detail(record) + self._test_response_ok(rec_type, data, identifier) + identifier = record.default_code + self._test_response_ok(rec_type, data, identifier) + + def test_scan_location(self): + record = self.stock_location + rec_type = "location" + identifier = record.barcode + data = self.data_detail.location_detail(record) + self._test_response_ok(rec_type, data, identifier) + + def test_scan_package(self): + record = self.package + rec_type = "package" + identifier = record.name + data = self.data_detail.package_detail(record) + self._test_response_ok(rec_type, data, identifier) + + def test_scan_lot(self): + record = self.lot + rec_type = "lot" + identifier = record.name + data = self.data_detail.lot_detail(record) + self._test_response_ok(rec_type, data, identifier) + + def test_scan_transfer(self): + record = self.picking + rec_type = "transfer" + identifier = record.name + data = self.data_detail.picking_detail(record) + self._test_response_ok(rec_type, data, identifier) diff --git a/shopfloor/tests/test_single_pack_transfer.py b/shopfloor/tests/test_single_pack_transfer.py new file mode 100644 index 0000000000..b222063eae --- /dev/null +++ b/shopfloor/tests/test_single_pack_transfer.py @@ -0,0 +1,1121 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.tests.common import Form + +from .test_single_pack_transfer_base import SinglePackTransferCommonBase + +# pylint: disable=missing-return + + +class TestSinglePackTransfer(SinglePackTransferCommonBase): + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.pack_a = cls.env["stock.quant.package"].create( + {"location_id": cls.stock_location.id} + ) + cls.quant_a = ( + cls.env["stock.quant"] + .sudo() + .create( + { + "product_id": cls.product_a.id, + "location_id": cls.shelf1.id, + "quantity": 1, + "package_id": cls.pack_a.id, + } + ) + ) + cls.shelf1_2 = cls.shelf1.sudo().copy({"name": "Shelf 1_2"}) + cls.pack_b = cls.env["stock.quant.package"].create( + {"location_id": cls.stock_location.id} + ) + cls.quant_b = ( + cls.env["stock.quant"] + .sudo() + .create( + { + "product_id": cls.product_b.id, + "location_id": cls.shelf1_2.id, + "quantity": 1, + "package_id": cls.pack_b.id, + } + ) + ) + cls.picking = cls._create_initial_move( + lines=[(cls.product_a, 1), (cls.product_b, 1)] + ) + + @classmethod + def _create_initial_move(cls, lines): + """Create the move to satisfy the pre-condition before /start""" + picking_form = Form(cls.env["stock.picking"]) + picking_form.picking_type_id = cls.picking_type + picking_form.location_id = cls.stock_location + picking_form.location_dest_id = cls.shelf2 + for line in lines: + with picking_form.move_ids_without_package.new() as move: + move.product_id = line[0] + move.product_uom_qty = line[1] + picking = picking_form.save() + picking.action_confirm() + picking.action_assign() + return picking + + def _simulate_started(self, package): + """Replicate what the /start endpoint would do on the given package. + + Used to test the next endpoints (/validate and /cancel) + """ + package_level = self.picking.move_line_ids.package_level_id.filtered( + lambda pl: pl.package_id == package + ) + package_level.is_done = True + return package_level + + def _response_package_level_data(self, package_level): + return { + "id": package_level.id, + "name": package_level.package_id.name, + "location_src": self.data.location(package_level.location_id), + "location_dest": self.data.location(package_level.location_dest_id), + "picking": self.data.picking(self.picking), + "products": self.data.products(self.product_a), + } + + def test_start(self): + """Test the happy path for single pack transfer /start endpoint + + We scan the barcode of the pack (simplest use case). + + The pre-conditions: + + * A Pack exists in Stock/Shelf1. + * A stock picking exists to move the Pack from Stock/Shelf1 to + Stock/Shelf2. The move is "assigned". + + Expected result: + + * The package level of the move is set to "is_done". + + The next step in the workflow is to call /validate with the + package level that will set the move and picking to done. + """ + barcode = self.pack_a.name + params = {"barcode": barcode} + + package_level = self.picking.move_line_ids.package_level_id.filtered( + lambda pl: pl.package_id == self.pack_a + ) + self.assertFalse(package_level.is_done) + + # Simulate the client scanning a package's barcode, which + # in turns should start the operation in odoo + response = self.service.dispatch("start", params=params) + + self.assertTrue(package_level.is_done) + self.assert_response( + response, + next_state="scan_location", + data=dict( + self._response_package_level_data(package_level), + confirmation_required=False, + ), + ) + + def test_start_no_operation(self): + """Test /start when there is no operation to move the pack + + The pre-conditions: + + * A Pack exists in Stock/Shelf1. + * No stock picking exists to move the Pack from Stock/Shelf1 to + Stock/Shelf2, or the state is not assigned. + + Expected result: + + * Return a message + """ + barcode = self.pack_a.name + params = {"barcode": barcode} + self.picking.do_unreserve() + + # Simulate the client scanning a package's barcode, which + # in turns should start the operation in odoo + response = self.service.dispatch("start", params=params) + + self.assert_response( + response, + next_state="start", + message={ + "message_type": "error", + "body": "No pending operation for package {}.".format(self.pack_a.name), + }, + ) + + def test_start_no_operation_create(self): + """Test /start when there is no operation to move the pack, it is created + + The pre-conditions: + + * The option "Allow Move Creation" is turned on on the menu + * A Pack exists in Stock/Shelf1. + * No stock picking exists to move the Pack from Stock/Shelf1 to + Stock/Shelf2, or the state is not assigned. + + Expected result: + + * Create a stock.picking, move, package level and continue with the + workflow + """ + self.menu.sudo().allow_move_create = True + barcode = self.pack_a.name + params = {"barcode": barcode} + self.picking.do_unreserve() + + # Simulate the client scanning a package's barcode, which + # in turns should start the operation in odoo + response = self.service.dispatch("start", params=params) + + move_line = self.env["stock.move.line"].search( + [("package_id", "=", self.pack_a.id)] + ) + package_level = move_line.package_level_id + + self.assertTrue(package_level.is_done) + + expected_data = { + "id": package_level.id, + "name": package_level.package_id.name, + "location_src": self.data.location(self.shelf1), + "location_dest": self.data.location( + self.picking_type.default_location_dest_id + ), + "picking": self.data.picking(package_level.picking_id), + "products": self.data.products(self.product_a), + "confirmation_required": False, + } + + self.assert_response(response, next_state="scan_location", data=expected_data) + + def test_start_barcode_not_known(self): + """Test /start when the barcode is unknown + + The pre-conditions: + + * Nothing + + Expected result: + + * Return a message + """ + params = {"barcode": "THIS_BARCODE_DOES_NOT_EXIST"} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message={ + "message_type": "error", + "body": "The package THIS_BARCODE_DOES_NOT_EXIST" " doesn't exist", + }, + ) + + def test_start_pack_empty(self): + """Test /start when the scanned pack is empty + + The pre-conditions: + + * Nothing + + Expected result: + + * Return a message + """ + pack_empty = self.env["stock.quant.package"].create({}) + pack_code = pack_empty.name + params = {"barcode": pack_code} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.package_has_no_product_to_take(pack_code), + ) + + def test_start_pack_from_location(self): + """Test /start, finding the pack from location's barcode + + When we scan a location which contains only one pack, + we want to move this pack. + + The pre-conditions: + + * A Pack exists in Stock/Shelf1. + * A stock picking exists to move the Pack from Stock/Shelf1 to + Stock/Shelf2. The move is "assigned". + + Expected result: + + * The package level of the move is set to "is_done". + + The next step in the workflow is to call /validate with the + package level that will set the move and picking to done. + """ + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + # We only care about the fact that we jump to the next + # screen, so it found the pack. The details are already + # checked in the test_start test. + response, + next_state="scan_location", + data=self.ANY, + ) + + def test_start_pack_from_location_empty(self): + """Test /start, scan location's barcode without pack + + When we scan a location which contains no packs, + we ask the user to scan a pack. + + The pre-conditions: + + * No packs exists in Stock/Shelf2 + + Expected result: + + * Return a message + """ + barcode = self.shelf2.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message={ + "message_type": "error", + "body": "Location %s doesn't contain any package." + % (self.shelf2.name,), + }, + ) + + def test_start_pack_from_location_several_packs(self): + """Test /start, scan location's barcode with several packs + + When we scan a location which contains several packs, + we ask the user to scan a pack. + + The pre-conditions: + + * 2 packs exists in Stock/Shelf1. + + Expected result: + + * Return a message + """ + pack_b = self.env["stock.quant.package"].create( + {"location_id": self.stock_location.id} + ) + self.env["stock.quant"].sudo().create( + { + "product_id": self.product_a.id, + "location_id": self.shelf1.id, + "quantity": 1, + "package_id": pack_b.id, + } + ) + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message={ + "message_type": "warning", + "body": "Several packages found in %s, please scan a package." + % (self.shelf1.name,), + }, + ) + + def test_start_pack_outside_of_location(self): + """Test /start, scan a pack outside of the picking type location + + The pre-conditions: + + * A pack exists in a location outside of Stock (the default source + location of the picking type associated with the process) + + Expected result: + + * Return a message + """ + self.pack_a.location_id = self.dispatch_location + barcode = self.pack_a.name + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message={ + "message_type": "error", + "body": "You cannot work on a package (%s) outside of locations: %s" + % (self.pack_a.name, self.picking_type.default_location_src_id.name), + }, + ) + + def test_start_already_started(self): + """Test /start when it was already started + + We scan the barcode of the pack (simplest use case). + + The pre-conditions: + + * A Pack exists in Stock/Shelf1. + * A stock picking exists to move the Pack from Stock/Shelf1 to + Stock/Shelf2. The move is "assigned". + * Start is already called once + + Expected result: + + * Transition for confirmation with such message + + The next step in the workflow is to call /validate with the + package level that will set the move and picking to done. + """ + barcode = self.pack_a.name + params = {"barcode": barcode} + + package_level = self._simulate_started(self.pack_a) + self.assertTrue(package_level.is_done) + + # Simulate the client scanning a package's barcode, which + # in turns should start the operation in odoo + response = self.service.dispatch("start", params=params) + + self.assert_response( + response, + next_state="start", + message={ + "message_type": "warning", + "body": "Operation's already running." + " Would you like to take it over?", + }, + data=dict( + self._response_package_level_data(package_level), + confirmation_required=True, + ), + ) + + def test_validate(self): + """Test the happy path for single pack transfer /validate endpoint + + The pre-conditions: + + * /start has been called + * "completion info" is not active on the picking type + + Expected result: + + * The move associated to the package level is 'done' + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started(self.pack_a) + + # now, call the service to proceed with validation of the + # movement + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + }, + ) + + self.assert_response( + response, + next_state="start", + message={ + "message_type": "success", + "body": "The pack has been moved, you can scan a new pack.", + }, + ) + + self.assertRecordValues( + package_level.move_line_ids, + [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}], + ) + self.assertRecordValues( + package_level.move_line_ids.move_id, + [{"location_dest_id": self.shelf2.id, "state": "done"}], + ) + + def test_validate_completion_info(self): + """Test /validate when the picking is the last (show completion info) + + When the picking is the last, we display an information screen on the + js application. + + The pre-conditions: + + * /start has been called + * "completion info" is active on the picking type + * the picking must be the last (it must not have destination moves with + unprocessed origin moves) + + Expected result: + + * The move associated to the package level is 'done' + * The transition goes to the completion info screen instead of starting + over + """ + # activate the computation of this field, so we have a chance to + # transition to the 'show completion info' screen. + self.picking_type.sudo().display_completion_info = True + + # create a chained picking after the current one + next_picking = self.picking.copy( + { + "picking_type_id": self.wh.out_type_id.id, + "location_id": self.picking.location_dest_id.id, + "location_dest_id": self.customer_location.id, + } + ) + next_picking.move_ids.write( + {"move_orig_ids": [(6, 0, self.picking.move_ids.ids)]} + ) + next_picking.action_confirm() + + # process the first package + package_level_a = self._simulate_started(self.pack_a) + # validate the first package + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level_a.id, + "location_barcode": self.shelf2.barcode, + }, + ) + self.assertEqual(package_level_a.picking_id.state, "done") + # check the response: still no completion info message as we still have + # the second package to process + self.assert_response( + response, + next_state="start", + message={ + "message_type": "success", + "body": "The pack has been moved, you can scan a new pack.", + }, + ) + # process the second package + package_level_b = self._simulate_started(self.pack_b) + # validate the second package + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level_b.id, + "location_barcode": self.shelf2.barcode, + }, + ) + self.assertEqual(package_level_b.picking_id.state, "done") + self.assertNotEqual(package_level_a.picking_id, package_level_b.picking_id) + # check the response: the chained transfer is ready to be processed now + # that all the packages have been processed + self.assert_response( + response, + next_state="start", + popup={ + "body": "Last operation of transfer {}. Next operation " + "({}) is ready to proceed.".format(self.picking.name, next_picking.name) + }, + message={ + "message_type": "success", + "body": "The pack has been moved, you can scan a new pack.", + }, + ) + + def test_validate_not_found(self): + """Test a call on /validate on package level not found + + Expected result: + + * No change in odoo, Transition with a message + """ + response = self.service.dispatch( + "validate", + params={"package_level_id": -1, "location_barcode": self.shelf1.barcode}, + ) + + self.assert_response( + response, + next_state="start", + message={ + "message_type": "error", + "body": "This operation does not exist anymore.", + }, + ) + + def test_validate_location_not_found(self): + """Test a call on /validate on location not found + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, Transition with a message + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started(self.pack_a) + + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": "THIS_BARCODE_DOES_NOT_EXISTS", + }, + ) + + self.assert_response( + response, + next_state="scan_location", + data=self.ANY, + message={ + "message_type": "error", + "body": "No location found for this barcode.", + }, + ) + + def test_validate_location_forbidden(self): + """Test a call on /validate on a forbidden location (not child of + picking or move) + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, Transition with a message + + Note: the location is forbidden when a location is not a child + of the destination location of the picking used for the process + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started(self.pack_a) + + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + # this location is outside of the expected destination + "location_barcode": self.dispatch_location.barcode, + }, + ) + + self.assert_response( + response, + next_state="scan_location", + data=self.ANY, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + + def test_validate_location_move_not_child_of_picking_allowed(self): + """Test a call on /validate on a location not child of picking but child of move + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, Transition with a message + + Note: the location is allowed when the move location has changed and + that location is a child of the destination location of the move + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started(self.pack_a) + + location = package_level.location_dest_id.location_id + package_level.location_dest_id = location + package_level.move_line_ids.move_id.location_dest_id = location + + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": location.barcode, + }, + ) + + self.assert_response( + response, + next_state="start", + message={ + "message_type": "success", + "body": "The pack has been moved, you can scan a new pack.", + }, + ) + + def test_validate_location_to_confirm(self): + """Test a call on /validate on a location to confirm + + The pre-conditions: + + * /start has been called + + Expected result: + + * No change in odoo, transition with a message + + Note: a location to confirm is when a location is a child + of the destination location of the picking type used for the process + but not a child or the expected destination + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started(self.pack_a) + + sub_shelf1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "subshelf1", + "barcode": "subshelf1", + "location_id": self.shelf2.id, + } + ) + ) + sub_shelf2 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "subshelf2", + "barcode": "subshelf2", + "location_id": self.shelf2.id, + } + ) + ) + + # expected destination is 'shelf2', we'll scan shelf1 which must + # ask a confirmation to the user (it's still in the same picking type) + package_level.location_dest_id = sub_shelf1 + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": sub_shelf2.barcode, + }, + ) + + message = self.service._actions_for("message").confirm_location_changed( + sub_shelf1, sub_shelf2 + ) + self.assert_response( + response, + next_state="scan_location", + message=message, + data=dict( + self._response_package_level_data(package_level), + confirmation_required=True, + ), + ) + + def test_validate_location_with_confirm(self): + """Test a call on /validate on a different location with confirmation + + The pre-conditions: + + * /start has been called + + Expected result: + + * Ignore the fact that the scanned location is not the expected + * Change the destination of the move line to the scanned one + * The move associated to the package level is 'done' + + Note: a location to confirm is when a location is a child + of the destination location of the picking type used for the process + but not a child or the expected destination. + In such situation, the js application has to call /validate with + a ``confirmation`` flag. + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started(self.pack_a) + + # expected destination is 'shelf1', we'll scan shelf2 which must + # ask a confirmation to the user (it's still in the same picking type) + response = self.service.dispatch( + "validate", + params={ + "package_level_id": package_level.id, + "location_barcode": self.shelf2.barcode, + # acknowledge the change of destination + "confirmation": True, + }, + ) + + self.assert_response( + response, + next_state="start", + message={ + "message_type": "success", + "body": "The pack has been moved, you can scan a new pack.", + }, + ) + + self.assertRecordValues( + package_level.move_line_ids, + [{"qty_done": 1.0, "location_dest_id": self.shelf2.id, "state": "done"}], + ) + self.assertRecordValues( + package_level.move_line_ids.move_id, + [{"location_dest_id": self.shelf2.id, "state": "done"}], + ) + + def test_cancel(self): + """Test the happy path for single pack transfer /cancel endpoint + + The pre-conditions: + + * /start has been called + + Expected result: + + * The package level has is_done to False + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level = self._simulate_started(self.pack_a) + self.assertTrue(package_level.is_done) + + # keep references for later checks + move = package_level.move_line_ids.move_id + picking = move.picking_id + + # now, call the service to cancel + response = self.service.dispatch( + "cancel", params={"package_level_id": package_level.id} + ) + self.assertRecordValues(move, [{"state": "assigned"}]) + self.assertRecordValues(picking, [{"state": "assigned"}]) + self.assertRecordValues(package_level, [{"is_done": False}]) + + self.assert_response( + response, + next_state="start", + message={ + "message_type": "success", + "body": "Canceled, you can scan a new pack.", + }, + ) + + def test_cancel_already_canceled(self): + """Test a call on /cancel for already canceled package level + + The pre-conditions: + + * /start has been called + * /cancel has been called elsewhere or 'is_done' removed in odoo + + Expected result: + + * Nothing happens, transition with a message + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_level_a = self._simulate_started(self.pack_a) + # keep references for later checks + move_a = package_level_a.move_line_ids.move_id + move_lines_a = package_level_a.move_line_ids + picking = move_a.picking_id + # someone cancel the work started by our operator + move_lines_a.write({"qty_done": 0}) + move_a._action_cancel() + + # now, call the service to cancel the first package + response = self.service.dispatch( + "cancel", params={"package_level_id": package_level_a.id} + ) + self.assertRecordValues(move_a, [{"state": "cancel"}]) + self.assertRecordValues(picking, [{"state": "assigned"}]) + self.assertFalse(package_level_a.move_line_ids) + self.assertFalse(move_lines_a.exists()) + + self.assert_response( + response, + next_state="start", + message={ + "message_type": "error", + "body": "This operation does not exist anymore.", + }, + ) + package_level_b = self._simulate_started(self.pack_b) + # keep references for later checks + move_b = package_level_b.move_line_ids.move_id + # someone cancel the work started by our operator + move_lines_b = package_level_b.move_line_ids + move_lines_b.write({"qty_done": 0}) + move_b._action_cancel() + # then cancel the second package + response = self.service.dispatch( + "cancel", params={"package_level_id": package_level_b.id} + ) + self.assertRecordValues(move_b, [{"state": "cancel"}]) + picking.invalidate_recordset(["state"]) + self.assertRecordValues(picking, [{"state": "cancel"}]) + + def test_cancel_already_done(self): + """Test a call on /cancel on move already done + + The pre-conditions: + + * /start has been called + * /validate has been called or move set to done in odoo + + Expected result: + + * No change in odoo, Transition with a message + """ + # setup the picking as we need, like if the move line + # was already started by the first step (start operation) + package_levels = self._simulate_started(self.pack_a) | self._simulate_started( + self.pack_b + ) + + # keep references for later checks + moves = package_levels.move_line_ids.move_id + picking = moves.picking_id + + # someone cancel the work started by our operator + moves.extract_and_action_done() + + # now, call the service to cancel + response = self.service.dispatch( + "cancel", params={"package_level_id": package_levels[0].id} + ) + response = self.service.dispatch( + "cancel", params={"package_level_id": package_levels[1].id} + ) + self.assertRecordValues(moves, [{"state": "done"}, {"state": "done"}]) + self.assertRecordValues(picking, [{"state": "done"}]) + + self.assert_response( + response, + next_state="start", + message={"message_type": "info", "body": "Operation already processed."}, + ) + + def test_cancel_not_found(self): + """Test a call on /cancel on package level not found + + Expected result: + + * No change in odoo, Transition with a message + """ + response = self.service.dispatch("cancel", params={"package_level_id": -1}) + self.assert_response( + response, + next_state="start", + message={ + "message_type": "error", + "body": "This operation does not exist anymore.", + }, + ) + + +class SinglePackTransferSpecialCase(SinglePackTransferCommonBase): + def test_start_package_unreserve_ok(self): + """Test /start when the package was already reserved... + + ...for another picking type and unreserving is allowed. + + When we scan a location which contains only one package, + we want to move this package. + + The pre-conditions: + + * A package exists in Stock/Shelf1. + * A stock picking exists to move the package from Stock/Shelf1 to + Out with a different picking type. The move is "assigned". + * Another package exists in Stock + + Expected result: + + * the original transfer is reserved to move the other package from Stock + * a new transfer is created to move the package from Shelf1 + + The next step in the workflow is to call /validate with the + package level that will set the move and picking to done. + """ + self.menu.sudo().allow_move_create = True + self.menu.sudo().allow_unreserve_other_moves = True + + package = self.env["stock.quant.package"].create({}) + self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package) + # create a picking of another picking type + picking = self._create_picking( + picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)] + ) + picking.action_assign() + + # create another package that should be used when the picking will + # get re-assigned + package2 = self.env["stock.quant.package"].create({}) + self._update_qty_in_location( + self.stock_location, self.product_a, 10, package=package2 + ) + + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + + new_picking = self.env["stock.picking"].search( + [("picking_type_id", "=", self.picking_type.id)] + ) + self.assertEqual(len(new_picking), 1) + new_package_level = new_picking.package_level_ids + + self.assert_response( + # We only care about the fact that we jump to the next + # screen, so it found the pack. The details are already + # checked in the test_start test. + response, + next_state="scan_location", + data=dict( + self.service._data_after_package_scanned(new_package_level), + confirmation_required=False, + ), + ) + self.assertRecordValues( + picking.package_level_ids, [{"package_id": package2.id}] + ) + + self.assertRecordValues(new_package_level, [{"package_id": package.id}]) + + def test_start_package_unreserve_picked_error(self): + """Test /start when the package was already reserved... + + ...for another picking type and the other move is already picked. + + When we scan a location which contains only one package, + we want to move this package. + + The pre-conditions: + + * A package exists in Stock/Shelf1. + * A stock picking exists to move the package from Stock/Shelf1 to + Out with a different picking type. The move is "assigned". + + Expected result: + + * receive an error that we cannot pick the package + """ + self.menu.sudo().allow_move_create = True + self.menu.sudo().allow_unreserve_other_moves = True + + package = self.env["stock.quant.package"].create({}) + self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package) + # create a picking of another picking type + picking = self._create_picking( + picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)] + ) + picking.action_assign() + + # pick the goods + picking.move_line_ids.qty_done = 10 + + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.package_already_picked_by(package, picking), + ) + # no change in the picking + self.assertEqual(picking.state, "assigned") + self.assertRecordValues(picking.package_level_ids, [{"package_id": package.id}]) + + def test_start_package_unreserve_disabled_error(self): + """Test /start when the package was already reserved... + + ...for another picking type and unreserving is disallowed. + + When we scan a location which contains only one package, + we want to move this package. + + The pre-conditions: + + * A package exists in Stock/Shelf1. + * A stock picking exists to move the package from Stock/Shelf1 to + Out with a different picking type. The move is "assigned". + + Expected result: + + * receive an error that we cannot pick the package + """ + self.menu.sudo().allow_move_create = True + self.menu.sudo().allow_unreserve_other_moves = False + + package = self.env["stock.quant.package"].create({}) + self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package) + # create a picking of another picking type + picking = self._create_picking( + picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)] + ) + picking.action_assign() + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.package_already_picked_by(package, picking), + ) + # no change in the picking + self.assertEqual(picking.state, "assigned") + self.assertRecordValues(picking.package_level_ids, [{"package_id": package.id}]) + + def test_start_package_unreserve_no_create_error(self): + """Test /start when the package was already reserved... + + ...for another picking type and unreserving is allowed + and the option to create a move is not allowed. + + This test ensure that the unreservation of the first package + is rollbacked. + + """ + self.menu.sudo().allow_move_create = False + self.menu.sudo().allow_unreserve_other_moves = True + + package = self.env["stock.quant.package"].create({}) + self._update_qty_in_location(self.shelf1, self.product_a, 10, package=package) + # create a picking of another picking type + picking = self._create_picking( + picking_type=self.wh.out_type_id, lines=[(self.product_a, 10)] + ) + picking.action_assign() + self.assertEqual(picking.state, "assigned") + barcode = self.shelf1.barcode + params = {"barcode": barcode} + response = self.service.dispatch("start", params=params) + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.no_pending_operation_for_pack(package), + ) + # no change in the picking + self.assertEqual(picking.state, "assigned") + self.assertRecordValues(picking.package_level_ids, [{"package_id": package.id}]) diff --git a/shopfloor/tests/test_single_pack_transfer_base.py b/shopfloor/tests/test_single_pack_transfer_base.py new file mode 100644 index 0000000000..3b1fb8fd41 --- /dev/null +++ b/shopfloor/tests/test_single_pack_transfer_base.py @@ -0,0 +1,32 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .common import CommonCase + +# pylint: disable=missing-return + + +class SinglePackTransferCommonBase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_demo_single_pallet_transfer") + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + # we activate the move creation in tests when needed + cls.menu.sudo().allow_move_create = False + + # disable the completion on the picking type, we'll have specific test(s) + # to check the behavior of this screen + cls.picking_type.sudo().display_completion_info = False + + def setUp(self): + super().setUp() + self.service = self.get_service( + "single_pack_transfer", menu=self.menu, profile=self.profile + ) diff --git a/shopfloor/tests/test_single_pack_transfer_putaway.py b/shopfloor/tests/test_single_pack_transfer_putaway.py new file mode 100644 index 0000000000..e24aca783e --- /dev/null +++ b/shopfloor/tests/test_single_pack_transfer_putaway.py @@ -0,0 +1,104 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_single_pack_transfer_base import SinglePackTransferCommonBase + +# pylint: disable=missing-return + + +class TestSinglePackTransferPutaway(SinglePackTransferCommonBase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.pallets_storage_type = cls.env.ref( + "stock_storage_type.package_storage_type_pallets" + ) + cls.main_pallets_location = cls.env.ref( + "stock_storage_type.stock_location_pallets" + ) + cls.reserve_pallets_locations = cls.env.ref( + "stock_storage_type.stock_location_pallets_reserve" + ) + cls.all_pallets_locations = ( + cls.main_pallets_location.leaf_location_ids + | cls.reserve_pallets_locations.leaf_location_ids + ) + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.package = cls.env["stock.quant.package"].create( + { + # this will parameterize the putaway to use pallet locations, + # and if not, it will stay on the picking type's default dest. + "package_type_id": cls.pallets_storage_type.id, + } + ) + cls._update_qty_in_location(cls.shelf1, cls.product_a, 10, package=cls.package) + cls.menu.sudo().ignore_no_putaway_available = True + cls.menu.sudo().allow_move_create = True + + def test_normal_putaway(self): + """Ensure putaway is applied on moves""" + response = self.service.dispatch( + "start", params={"barcode": self.shelf1.barcode} + ) + self.assert_response( + response, + next_state="scan_location", + data=self.ANY, + ) + package_level_id = response["data"]["scan_location"]["id"] + package_level = self.env["stock.package_level"].browse(package_level_id) + self.assertIn(package_level.location_dest_id, self.all_pallets_locations) + + def test_ignore_no_putaway_available(self): + """Ignore no putaway available is activated on the menu + + In this case, when no putaway is possible, the changes + are rollbacked and an error is returned. + """ + for location in self.all_pallets_locations: + package = self.env["stock.quant.package"].create( + {"package_type_id": self.pallets_storage_type.id} + ) + self._update_qty_in_location(location, self.product_a, 10, package=package) + + response = self.service.dispatch( + "start", params={"barcode": self.shelf1.barcode} + ) + self.assert_response( + response, + next_state="start", + message=self.service.msg_store.no_putaway_destination_available(), + ) + + package_levels = self.env["stock.package_level"].search( + [("package_id", "=", self.package.id)] + ) + # no package level created to move the package + self.assertFalse(package_levels) + + def test_putaway_move_dest_not_child_of_picking_dest(self): + """Putaway is applied on move but the destination location is not a + child of the default picking type destination location. + Case where the picking is created by scanning a package level. Then the + move destination is according to the putaway and valid. + """ + # Change the default destination location of the picking type + # to get it outside of the putaway destination + self.picking_type.sudo().default_location_dest_id = self.main_pallets_location + # Create a standard putaway to move the package from pallet storage + # to a unrelated one (outside of the pallet storage tree) + self.env["stock.putaway.rule"].sudo().create( + { + "product_id": self.product_a.id, + "location_in_id": self.picking_type.default_location_dest_id.id, + "location_out_id": self.env.ref("stock.location_refrigerator_small").id, + } + ) + # Check the result + response = self.service.dispatch( + "start", params={"barcode": self.shelf1.barcode} + ) + self.assert_response(response, next_state="scan_location", data=self.ANY) diff --git a/shopfloor/tests/test_stock_split.py b/shopfloor/tests/test_stock_split.py new file mode 100644 index 0000000000..ebe60d9191 --- /dev/null +++ b/shopfloor/tests/test_stock_split.py @@ -0,0 +1,204 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +# pylint: disable=missing-return + + +@tagged("post_install", "-at_install") +class TestStockSplit(TransactionCase): + @classmethod + def setUpClass(cls): + super(TestStockSplit, cls).setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.warehouse = cls.env.ref("stock.warehouse0") + cls.warehouse.delivery_steps = "pick_pack_ship" + cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.pack_location = cls.warehouse.wh_pack_stock_loc_id + cls.ship_location = cls.warehouse.wh_output_stock_loc_id + cls.stock_location = cls.env.ref("stock.stock_location_stock") + # Create products + cls.product_a = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product A", + "type": "product", + "default_code": "A", + "barcode": "A", + "weight": 2, + } + ) + ) + cls.product_a_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_a.id, + "barcode": "ProductABox", + } + ) + ) + cls.product_b = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product B", + "type": "product", + "default_code": "B", + "barcode": "B", + "weight": 2, + } + ) + ) + cls.product_b_packaging = ( + cls.env["product.packaging"] + .sudo() + .create( + { + "name": "Box", + "product_id": cls.product_b.id, + "barcode": "ProductBBox", + } + ) + ) + # Put product_a quantities in different packages to get several move lines + cls.package_1 = cls.env["stock.quant.package"].create({"name": "PACKAGE_1"}) + cls.package_2 = cls.env["stock.quant.package"].create({"name": "PACKAGE_2"}) + cls.package_3 = cls.env["stock.quant.package"].create({"name": "PACKAGE_3"}) + cls.package_4 = cls.env["stock.quant.package"].create({"name": "PACKAGE_4"}) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 6, package=cls.package_1 + ) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 4, package=cls.package_2 + ) + cls._update_qty_in_location( + cls.stock_location, cls.product_a, 5, package=cls.package_3 + ) + # Put product_b quantities in stock + cls._update_qty_in_location(cls.stock_location, cls.product_b, 10) + # Create the pick/pack/ship transfer + cls.ship_move_a = cls.env["stock.move"].create( + { + "name": cls.product_a.display_name, + "product_id": cls.product_a.id, + "product_uom_qty": 15.0, + "product_uom": cls.product_a.uom_id.id, + "location_id": cls.ship_location.id, + "location_dest_id": cls.customer_location.id, + "warehouse_id": cls.warehouse.id, + "picking_type_id": cls.warehouse.out_type_id.id, + "procure_method": "make_to_order", + "state": "draft", + } + ) + cls.ship_move_b = cls.env["stock.move"].create( + { + "name": cls.product_b.display_name, + "product_id": cls.product_b.id, + "product_uom_qty": 4, + "product_uom": cls.product_b.uom_id.id, + "location_id": cls.ship_location.id, + "location_dest_id": cls.customer_location.id, + "warehouse_id": cls.warehouse.id, + "picking_type_id": cls.warehouse.out_type_id.id, + "procure_method": "make_to_order", + "state": "draft", + } + ) + (cls.ship_move_a | cls.ship_move_b)._assign_picking() + (cls.ship_move_a | cls.ship_move_b)._action_confirm(merge=False) + cls.pack_move_a = cls.ship_move_a.move_orig_ids[0] + cls.pick_move_a = cls.pack_move_a.move_orig_ids[0] + cls.pack_move_b = cls.ship_move_b.move_orig_ids[0] + cls.pick_move_b = cls.pack_move_b.move_orig_ids[0] + cls.picking = cls.pick_move_a.picking_id + cls.packing = cls.pack_move_a.picking_id + cls.picking.action_assign() + + @classmethod + def _update_qty_in_location( + cls, location, product, quantity, package=None, lot=None + ): + quants = cls.env["stock.quant"]._gather( + product, location, lot_id=lot, package_id=package, strict=True + ) + # this method adds the quantity to the current quantity, so remove it + quantity -= sum(quants.mapped("quantity")) + cls.env["stock.quant"]._update_available_quantity( + product, location, quantity, package_id=package, lot_id=lot + ) + + def test_split_pickings_from_source_location(self): + dest_location = self.pick_move_a.location_dest_id.sudo().copy( + { + "name": self.pick_move_a.location_dest_id.name + "_2", + "barcode": self.pick_move_a.location_dest_id.barcode + "_2", + "location_id": self.pick_move_a.location_dest_id.id, + } + ) + # Pick goods from stock and move some of them to a different destination + self.assertEqual(self.pick_move_a.state, "assigned") + for i, move_line in enumerate(self.pick_move_a.move_line_ids): + move_line.qty_done = move_line.reserved_uom_qty + if i % 2: + move_line.location_dest_id = dest_location + self.pick_move_a.extract_and_action_done() + self.assertEqual(self.pick_move_a.state, "done") + # Pack step, we want to split move lines from common source location + self.assertEqual(self.pack_move_a.state, "assigned") + move_lines_to_process = self.pack_move_a.move_line_ids.filtered( + lambda ml: ml.location_id == dest_location + ) + self.assertEqual(len(self.pack_move_a.move_line_ids), 3) + self.assertEqual(len(self.packing.package_level_ids), 3) + self.assertEqual(len(move_lines_to_process), 1) + new_packing = move_lines_to_process._split_pickings_from_source_location() + self.assertEqual(len(self.packing.package_level_ids), 2) + self.assertEqual(len(new_packing.package_level_ids), 1) + self.assertEqual(len(new_packing.move_line_ids), 1) + self.assertTrue(new_packing != self.packing) + self.assertEqual(new_packing.backorder_id, self.packing) + self.assertEqual( + self.pick_move_a.move_dest_ids.picking_id, self.packing | new_packing + ) + self.assertEqual(move_lines_to_process.state, "assigned") + self.assertEqual( + set(self.pack_move_a.move_line_ids.mapped("state")), {"assigned"} + ) + + def test_extract_and_action_done_one_assigned_move(self): + self.assertFalse(self.picking.backorder_ids) + self.assertEqual(self.picking.state, "assigned") + for move_line in self.pick_move_b.move_line_ids: + move_line.qty_done = move_line.reserved_uom_qty + self.pick_move_b.extract_and_action_done() + new_picking = self.picking.backorder_ids + self.assertTrue(new_picking) + # Check move lines repartition + self.assertNotIn(self.pick_move_b, self.picking.move_ids) + self.assertEqual(new_picking.move_ids, self.pick_move_b) + # Check states + self.assertEqual(self.picking.state, "assigned") + self.assertEqual(self.pick_move_b.state, "done") + self.assertEqual(new_picking.state, "done") + + def test_extract_and_action_done_multiple_assigned_moves(self): + self.assertFalse(self.picking.backorder_ids) + self.assertEqual(self.picking.state, "assigned") + for move_line in self.picking.move_line_ids: + move_line.qty_done = move_line.reserved_uom_qty + self.picking.move_ids.extract_and_action_done() + # No backorder as all moves of the picking have been validated + new_picking = self.picking.backorder_ids + self.assertFalse(new_picking) + # Check move lines repartition + self.assertEqual(len(self.picking.move_ids), 2) + # Check states + self.assertEqual(self.picking.state, "done") diff --git a/shopfloor/tests/test_user.py b/shopfloor/tests/test_user.py new file mode 100644 index 0000000000..70ae9dbfcc --- /dev/null +++ b/shopfloor/tests/test_user.py @@ -0,0 +1,42 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_menu_base import CommonMenuCase + +# pylint: disable=missing-return + + +class UserCase(CommonMenuCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + ref = cls.env.ref + cls.wms_profile = ref("shopfloor.profile_demo_1") + cls.wms_profile2 = ref("shopfloor.profile_demo_2") + + def test_menu_by_profile(self): + """Request /user/menu w/ a specific profile but no picking types""" + + service = self.get_service("user", profile=self.wms_profile) + menus = ( + self.env["shopfloor.menu"] + .sudo() + .search([("profile_id", "=", self.wms_profile.id)]) + ) + response = service.dispatch("menu") + self.assert_response( + response, + data={"menus": [self._data_for_menu_item(menu) for menu in menus]}, + ) + + service = self.get_service("user", profile=self.wms_profile2) + menus = ( + self.env["shopfloor.menu"] + .sudo() + .search([("profile_id", "=", self.wms_profile2.id)]) + ) + response = service.dispatch("menu") + self.assert_response( + response, + data={"menus": [self._data_for_menu_item(menu) for menu in menus]}, + ) diff --git a/shopfloor/tests/test_zone_picking_base.py b/shopfloor/tests/test_zone_picking_base.py new file mode 100644 index 0000000000..67c30b8951 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_base.py @@ -0,0 +1,608 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .common import CommonCase + +# pylint: disable=missing-return + + +class ZonePickingCommonCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_demo_zone_picking") + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassUsers(cls): + super().setUpClassUsers() + Users = cls.env["res.users"].sudo().with_context(no_reset_password=True) + cls.stock_user2 = Users.create( + { + "name": "Paul Posichon", + "login": "paulposichon", + "email": "paul.posichon@example.com", + "notification_type": "inbox", + "groups_id": [(6, 0, [cls.env.ref("stock.group_stock_user").id])], + } + ) + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.packing_location.sudo().active = True + # We want to limit the tests to a dedicated location in Stock/ to not + # be bothered with pickings brought by demo data + cls.zone_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone location", + "location_id": cls.stock_location.id, + "barcode": "ZONE_LOCATION", + } + ) + ) + # Set default location for our picking type + cls.menu.picking_type_ids[0].sudo().default_location_src_id = cls.zone_location + cls.zone_sublocation1 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 1", + "location_id": cls.zone_location.id, + "barcode": "ZONE_SUBLOCATION_1", + } + ) + ) + cls.zone_sublocation2 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 2", + "location_id": cls.zone_location.id, + "barcode": "ZONE_SUBLOCATION_2", + } + ) + ) + cls.zone_sublocation3 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 3", + "location_id": cls.zone_location.id, + "barcode": "ZONE_SUBLOCATION_3", + } + ) + ) + cls.zone_sublocation4 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 4", + "location_id": cls.zone_location.id, + "barcode": "ZONE_SUBLOCATION_4", + } + ) + ) + cls.zone_sublocation5 = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Zone sub-location 5", + "location_id": cls.zone_location.id, + "barcode": "ZONE_SUBLOCATION_5", + } + ) + ) + cls.packing_sublocation_a = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Packing Sublocation A", + "location_id": cls.packing_location.id, + "barcode": "PACKING_SUBLOCATION_A", + } + ) + ) + cls.packing_sublocation_b = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Packing Sublocation B", + "location_id": cls.packing_location.id, + "barcode": "PACKING_SUBLOCATION_B", + } + ) + ) + cls.product_e = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product E", + "type": "product", + "default_code": "E", + "barcode": "E", + "weight": 3, + } + ) + ) + cls.product_f = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product F", + "type": "product", + "default_code": "F", + "barcode": "F", + "weight": 3, + } + ) + ) + cls.product_g = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product G", + "type": "product", + "default_code": "G", + "barcode": "G", + "weight": 3, + } + ) + ) + cls.product_h = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product H", + "type": "product", + "default_code": "H", + "barcode": "H", + "weight": 3, + } + ) + ) + products = ( + cls.product_a + + cls.product_b + + cls.product_c + + cls.product_d + + cls.product_e + + cls.product_f + + cls.product_g + + cls.product_h + ) + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + # 1 product in a package available in zone_sublocation1 + cls.picking1 = picking1 = cls._create_picking(lines=[(cls.product_a, 10)]) + cls._fill_stock_for_moves( + picking1.move_ids, in_package=True, location=cls.zone_sublocation1 + ) + # 2 products with lots available in zone_sublocation2 + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_b, 10), (cls.product_c, 10)] + ) + cls._fill_stock_for_moves( + picking2.move_ids, in_lot=True, location=cls.zone_sublocation2 + ) + # 1 product (no package, no lot) available in zone_sublocation3 + cls.picking3 = picking3 = cls._create_picking(lines=[(cls.product_d, 10)]) + cls._fill_stock_for_moves(picking3.move_ids, location=cls.zone_sublocation3) + # 1 product, available in zone_sublocation3 and zone_sublocation4 + # Put product_e quantities in two different source locations to get + # two stock move lines (6 and 4 to satisfy 10 qties) + cls.picking4 = picking4 = cls._create_picking(lines=[(cls.product_e, 10)]) + cls._update_qty_in_location(cls.zone_sublocation3, cls.product_e, 6) + cls._update_qty_in_location(cls.zone_sublocation4, cls.product_e, 4) + # 2 products in a package available in zone_sublocation4 + cls.picking5 = picking5 = cls._create_picking( + lines=[(cls.product_b, 10), (cls.product_f, 10)] + ) + cls._fill_stock_for_moves( + picking5.move_ids, + in_package=True, + same_package=False, + location=cls.zone_sublocation4, + ) + # 2 products available in zone_sublocation5, but one is partially available + cls.picking6 = picking6 = cls._create_picking( + lines=[(cls.product_g, 6), (cls.product_h, 6)] + ) + cls._update_qty_in_location(cls.zone_sublocation5, cls.product_g, 6) + cls._update_qty_in_location(cls.zone_sublocation5, cls.product_h, 3) + + cls.pickings = picking1 | picking2 | picking3 | picking4 | picking5 | picking6 + cls.pickings.action_assign() + # Some records not related at all to the processed move lines + cls.free_package = cls.env["stock.quant.package"].create( + {"name": "FREE_PACKAGE"} + ) + cls.free_lot = cls.env["stock.lot"].create( + { + "name": "FREE_LOT", + "product_id": cls.product_a.id, + "company_id": cls.env.company.id, + } + ) + cls.free_product = ( + cls.env["product.product"] + .sudo() + .create({"name": "FREE_PRODUCT", "barcode": "FREE_PRODUCT"}) + ) + + def setUp(self): + super().setUp() + self.service = self.get_service( + "zone_picking", + menu=self.menu, + profile=self.profile, + current_zone_location=self.zone_location, + current_picking_type=self.picking_type, + ) + + def _assert_response_select_zone(self, response, zone_locations, message=None): + data = {"zones": self.service._data_for_select_zone(zone_locations)} + self.assert_response( + response, + next_state="start", + data=data, + message=message, + ) + + def assert_response_start(self, response, zone_locations=None, message=None): + if zone_locations is None: + zone_locations = self.zone_location.child_ids + self._assert_response_select_zone(response, zone_locations, message=message) + + def _assert_response_select_picking_type( + self, state, response, zone_location, picking_types, message=None + ): + data = self.service._data_for_select_picking_type(zone_location, picking_types) + self.assert_response( + response, + next_state=state, + data=data, + message=message, + ) + + def assert_response_select_picking_type( + self, response, zone_location, picking_types, message=None + ): + self._assert_response_select_picking_type( + "select_picking_type", + response, + zone_location, + picking_types, + message=message, + ) + + def _assert_response_select_line( + self, + state, + response, + zone_location, + picking_type, + move_lines, + message=None, + popup=None, + confirmation_required=False, + product=None, + sublocation=None, + location_first=None, + package=None, + ): + data = { + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_lines": self.data.move_lines(move_lines, with_picking=True), + "confirmation_required": confirmation_required, + "scan_location_or_pack_first": location_first, + } + if product: + data["product"] = self.data.product(product) + if package: + data["package"] = self.data.package(package) + if sublocation: + data["sublocation"] = self.data.location(sublocation) + for data_move_line in data["move_lines"]: + move_line = self.env["stock.move.line"].browse(data_move_line["id"]) + data_move_line[ + "location_will_be_empty" + ] = move_line.location_id.planned_qty_in_location_is_empty(move_line) + self.assert_response( + response, + next_state=state, + data=data, + message=message, + popup=popup, + ) + + def assert_response_select_line( + self, + response, + zone_location, + picking_type, + move_lines, + message=None, + popup=None, + confirmation_required=False, + product=None, + sublocation=None, + location_first=False, + package=False, + ): + self._assert_response_select_line( + "select_line", + response, + zone_location, + picking_type, + move_lines, + message=message, + popup=popup, + confirmation_required=confirmation_required, + product=product, + sublocation=sublocation, + location_first=location_first, + package=package, + ) + + def _assert_response_set_line_destination( + self, + state, + response, + zone_location, + picking_type, + move_line, + message=None, + confirmation_required=False, + qty_done=None, + ): + expected_move_line = self.data.move_line(move_line, with_picking=True) + if qty_done is not None: + expected_move_line["qty_done"] = qty_done + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_line": expected_move_line, + "confirmation_required": confirmation_required, + }, + message=message, + ) + + def assert_response_set_line_destination( + self, + response, + zone_location, + picking_type, + move_line, + message=None, + confirmation_required=False, + qty_done=None, + ): + self._assert_response_set_line_destination( + "set_line_destination", + response, + zone_location, + picking_type, + move_line, + message=message, + confirmation_required=confirmation_required, + qty_done=qty_done, + ) + + def _assert_response_zero_check( + self, + state, + response, + zone_location, + picking_type, + move_line, + message=None, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "location": self.data.location(move_line.location_id), + "move_line": self.data.move_line(move_line), + }, + message=message, + ) + + def assert_response_zero_check( + self, + response, + zone_location, + picking_type, + move_line, + message=None, + ): + self._assert_response_zero_check( + "zero_check", + response, + zone_location, + picking_type, + move_line, + message=message, + ) + + def _assert_response_change_pack_lot( + self, + state, + response, + zone_location, + picking_type, + move_line, + message=None, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_line": self.data.move_line(move_line, with_picking=True), + }, + message=message, + ) + + def assert_response_change_pack_lot( + self, + response, + zone_location, + picking_type, + move_line, + message=None, + ): + self._assert_response_change_pack_lot( + "change_pack_lot", + response, + zone_location, + picking_type, + move_line, + message=message, + ) + + def _assert_response_unload_set_destination( + self, + state, + response, + zone_location, + picking_type, + move_line, + message=None, + confirmation_required=False, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_line": self.data.move_line(move_line, with_picking=True), + "confirmation_required": confirmation_required, + }, + message=message, + ) + + def assert_response_unload_set_destination( + self, + response, + zone_location, + picking_type, + move_line, + message=None, + confirmation_required=False, + ): + self._assert_response_unload_set_destination( + "unload_set_destination", + response, + zone_location, + picking_type, + move_line, + message=message, + confirmation_required=confirmation_required, + ) + + def _assert_response_unload_all( + self, + state, + response, + zone_location, + picking_type, + move_lines, + message=None, + confirmation_required=False, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_lines": self.data.move_lines(move_lines, with_picking=True), + "confirmation_required": confirmation_required, + }, + message=message, + ) + + def assert_response_unload_all( + self, + response, + zone_location, + picking_type, + move_lines, + message=None, + confirmation_required=False, + ): + self._assert_response_unload_all( + "unload_all", + response, + zone_location, + picking_type, + move_lines, + message=message, + confirmation_required=confirmation_required, + ) + + def _assert_response_unload_single( + self, + state, + response, + zone_location, + picking_type, + move_line, + message=None, + popup=None, + ): + self.assert_response( + response, + next_state=state, + data={ + "zone_location": self.data.location(zone_location), + "picking_type": self.data.picking_type(picking_type), + "move_line": self.data.move_line(move_line, with_picking=True), + }, + message=message, + popup=popup, + ) + + def assert_response_unload_single( + self, response, zone_location, picking_type, move_line, message=None, popup=None + ): + self._assert_response_unload_single( + "unload_single", + response, + zone_location, + picking_type, + move_line, + message=message, + popup=popup, + ) diff --git a/shopfloor/tests/test_zone_picking_change_pack_lot.py b/shopfloor/tests/test_zone_picking_change_pack_lot.py new file mode 100644 index 0000000000..5973b4be86 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_change_pack_lot.py @@ -0,0 +1,140 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingChangePackLotCase(ZonePickingCommonCase): + """Tests for endpoint used from change_pack_lot + + * /change_pack_lot + + Only simple cases are tested to check the flow of responses on success and + error, the "change.package.lot" component is tested in its own tests. + """ + + def test_change_pack_lot_no_package_or_lot_for_barcode(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + barcode = "UNKNOWN" + response = self.service.dispatch( + "change_pack_lot", + params={"move_line_id": move_line.id, "barcode": barcode}, + ) + self.assert_response_change_pack_lot( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.no_package_or_lot_for_barcode(barcode), + ) + + def test_change_pack_lot_change_pack_ok(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + previous_package = move_line.package_id + # ensure we have our new package in the same location + self._update_qty_in_location( + move_line.location_id, + move_line.product_id, + move_line.reserved_uom_qty, + package=self.free_package, + ) + # change package + response = self.service.dispatch( + "change_pack_lot", + params={"move_line_id": move_line.id, "barcode": self.free_package.name}, + ) + # check data + self.assertRecordValues( + move_line, + [ + { + "package_id": self.free_package.id, + "result_package_id": self.free_package.id, + } + ], + ) + self.assertRecordValues( + move_line.package_level_id, [{"package_id": self.free_package.id}] + ) + # check that reservations have been updated + previous_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ("package_id", "=", previous_package.id), + ] + ) + self.assertEqual(previous_quant.quantity, 10) + self.assertEqual(previous_quant.reserved_quantity, 0) + new_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ("package_id", "=", self.free_package.id), + ] + ) + self.assertEqual(new_quant.quantity, 10) + self.assertEqual(new_quant.reserved_quantity, 10) + # check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.package_replaced_by_package( + previous_package, self.free_package + ), + ) + + def test_change_pack_lot_change_lot_ok(self): + zone_location = self.zone_location + picking_type = self.picking2.picking_type_id + move_line = self.picking2.move_line_ids[0] + previous_lot = move_line.lot_id + self.free_lot.product_id = move_line.product_id + # ensure we have our new lot in the same location + self._update_qty_in_location( + move_line.location_id, + move_line.product_id, + move_line.reserved_uom_qty, + lot=self.free_lot, + ) + # change lot + response = self.service.dispatch( + "change_pack_lot", + params={"move_line_id": move_line.id, "barcode": self.free_lot.name}, + ) + # check data + self.assertRecordValues(move_line, [{"lot_id": self.free_lot.id}]) + # check that reservations have been updated + previous_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ("lot_id", "=", previous_lot.id), + ] + ) + self.assertEqual(previous_quant.quantity, 10) + self.assertEqual(previous_quant.reserved_quantity, 0) + new_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", move_line.location_id.id), + ("product_id", "=", move_line.product_id.id), + ("lot_id", "=", self.free_lot.id), + ] + ) + self.assertEqual(new_quant.quantity, 10) + self.assertEqual(new_quant.reserved_quantity, 10) + # check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.lot_replaced_by_lot( + previous_lot, self.free_lot + ), + ) diff --git a/shopfloor/tests/test_zone_picking_select_line.py b/shopfloor/tests/test_zone_picking_select_line.py new file mode 100644 index 0000000000..0414bf98a1 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_select_line.py @@ -0,0 +1,723 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields + +from .test_zone_picking_base import ZonePickingCommonCase + + +# pylint: disable=missing-return +class ZonePickingSelectLineCase(ZonePickingCommonCase): + """Tests for endpoint used from select_line + + * /list_move_lines (to change order) + * /scan_source + * /prepare_unload + + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + + def test_list_move_lines_order(self): + self.zone_sublocation2.name = "AAA " + self.zone_sublocation2.name + + # Test by location + today = fields.Datetime.today() + future = fields.Datetime.add( + fields.Datetime.end_of(fields.Datetime.today(), "day"), days=2 + ) + # change date to lines in the same location + move1 = self.picking2.move_ids[0] + move1.write({"date": today}) + move1_line = move1.move_line_ids[0] + move2 = self.picking2.move_ids[1] + move2.write({"date": future}) + move2_line = move2.move_line_ids[0] + + self.service.work.current_lines_order = "location" + move_lines = self.service._find_location_move_lines() + order_mapping = {line: i for i, line in enumerate(move_lines)} + self.assertTrue(order_mapping[move1_line] < order_mapping[move2_line]) + + # swap dates + move2.write({"date": today}) + move1.write({"date": future}) + move_lines = self.service._find_location_move_lines() + order_mapping = {line: i for i, line in enumerate(move_lines)} + self.assertTrue(order_mapping[move1_line] > order_mapping[move2_line]) + + # Test by priority + self.picking2.write({"priority": "0"}) + (self.pickings - self.picking2).write({"priority": "1"}) + + self.service.work.current_lines_order = "priority" + move_lines = self.service._find_location_move_lines() + order_mapping = {line: i for i, line in enumerate(move_lines)} + # picking2 lines stay at the end as they are low priority + self.assertTrue(move1_line in move_lines[-2:]) + self.assertTrue(move2_line in move_lines[-2:]) + # but move1_line comes after the other + self.assertTrue(order_mapping[move1_line] > order_mapping[move2_line]) + + # swap dates again + move2.write({"date": future}) + move1.write({"date": today}) + # and increase priority + self.picking2.write({"priority": "1"}) + (self.pickings - self.picking2).write({"priority": "0"}) + move_lines = self.service._find_location_move_lines() + order_mapping = {line: i for i, line in enumerate(move_lines)} + self.assertEqual(order_mapping[move1_line], 0) + self.assertEqual(order_mapping[move2_line], 1) + + def test_list_move_lines_order_by_location(self): + self.service.work.current_lines_order = "location" + response = self.service.dispatch("list_move_lines", params={}) + move_lines = self.service._find_location_move_lines() + res = [ + x["location_src"]["name"] + for x in response["data"]["select_line"]["move_lines"] + ] + self.assertEqual(res, [x.location_id.name for x in move_lines]) + self.maxDiff = None + self.assert_response_select_line( + response, + self.zone_location, + self.picking1.picking_type_id, + move_lines, + ) + + def test_list_move_lines_order_by_priority(self): + response = self.service.dispatch("list_move_lines", params={}) + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + self.zone_location, + self.picking_type, + move_lines, + ) + + def test_scan_source_barcode_location_not_allowed(self): + """Scan source: scanned location not allowed.""" + response = self.service.dispatch( + "scan_source", + params={"barcode": self.customer_location.barcode}, + ) + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + self.zone_location, + self.picking_type, + move_lines, + message=self.service.msg_store.location_not_allowed(), + ) + + def test_scan_source_barcode_location_one_move_line(self): + """Scan source: scanned location 'Zone sub-location 1' contains only + one move line, next step 'set_line_destination' expected. + """ + response = self.service.dispatch( + "scan_source", + params={"barcode": self.zone_sublocation1.barcode}, + ) + move_line = self.picking1.move_line_ids + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=10.0, + ) + + def test_scan_source_barcode_location_two_move_lines_same_product(self): + """Scan source: scanned location 'Zone sub-location 1' contains two lines. + + Lines have the same product/package/lot, + they get processed one after the other, + next step 'set_line_destination' expected. + """ + package = self.picking1.move_line_ids.mapped("package_id")[0] + new_picking = self._create_picking(lines=[(self.product_a, 20)]) + self._fill_stock_for_moves( + new_picking.move_ids, in_package=package, location=self.zone_sublocation1 + ) + new_picking.action_assign() + response = self.service.dispatch( + "scan_source", + params={"barcode": self.zone_sublocation1.barcode}, + ) + move_line = self.picking1.move_line_ids + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=10.0, + ) + # first line done + move_line.qty_done = move_line.reserved_uom_qty + # get the next one + response = self.service.dispatch( + "scan_source", + params={"barcode": self.zone_sublocation1.barcode}, + ) + move_line = new_picking.move_line_ids + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=10.0, + ) + + def test_scan_source_barcode_location_several_move_lines(self): + """Scan source: scanned location 'Zone sub-location 2' contains two + move lines, next step 'select_line' expected with the list of these + move lines. + """ + response = self.service.dispatch( + "scan_source", + params={"barcode": self.zone_sublocation2.barcode}, + ) + move_lines = self.pickings.move_line_ids.filtered( + lambda l: l.location_id == self.zone_sublocation2 + ).sorted( + self.service.search_move_line._sort_key_move_lines(self.service.lines_order) + ) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.several_products_in_location( + self.zone_sublocation2 + ), + sublocation=self.zone_sublocation2, + location_first=False, + ) + + def test_scan_source_barcode_package(self): + """Scan source: scanned package has one related move line, + next step 'set_line_destination' expected on it. + """ + package = self.picking1.package_level_ids[0].package_id + response = self.service.dispatch( + "scan_source", + params={"barcode": package.name}, + ) + move_lines = self.service._find_location_move_lines( + package=package, + ) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + move_line = move_lines[0] + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=10.0, + ) + + def test_scan_source_barcode_package_not_found(self): + """Scan source: scanned package has no related move line, + next step 'select_line' expected. + """ + self.free_package.location_id = self.zone_location + pack_code = self.free_package.name + response = self.service.dispatch( + "scan_source", + params={"barcode": pack_code}, + ) + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.package_has_no_product_to_take(pack_code), + ) + + def test_scan_source_barcode_package_not_exist(self): + """Scan source: scanned package that does not exist in the system + next step 'select_line' expected. + """ + response = self.service.dispatch( + "scan_source", + params={"barcode": "P-Unknown"}, + ) + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.barcode_not_found(), + ) + + def test_scan_source_package_many_products(self): + """Scan source: scanned package that several product, aborting + next step 'select_line expected. + """ + pack = self.picking1.package_level_ids[0].package_id + self._update_qty_in_location(pack.location_id, self.product_b, 2, pack) + response = self.service.dispatch( + "scan_source", + params={"barcode": pack.name}, + ) + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation1 + ) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + package=pack, + message=self.service.msg_store.several_products_in_package(pack), + location_first=False, + ) + + def test_scan_source_barcode_package_can_replace_in_line(self): + """Scan source: scanned package has no related line but can replace + next step 'select_line' expected with confirmation required set. + Scan source: 2nd time the package replace package line with new package + next step 'set_line_destination'. + """ + # Add the same product same package in the same location to use as replacement + picking1b = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking1b.move_ids, in_package=True, location=self.zone_sublocation1 + ) + picking1b.action_assign() + package1b = picking1b.package_level_ids[0].package_id + picking1b.action_cancel() + package1 = self.picking1.package_level_ids[0].package_id + # 1st scan + response = self.service.dispatch( + "scan_source", + params={"barcode": package1b.name}, + ) + move_lines = self.service._find_location_move_lines( + package=package1, + ) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.package_different_change(), + confirmation_required=True, + ) + self.assertEqual(self.picking1.package_level_ids[0].package_id, package1) + # 2nd scan + response = self.service.dispatch( + "scan_source", + params={"barcode": package1b.name, "confirmation": True}, + ) + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_lines[0], + message=self.service.msg_store.package_replaced_by_package( + package1, package1b + ), + ) + # Check the package has been changed on the move line + self.assertEqual(self.picking1.package_level_ids[0].package_id, package1b) + + def test_scan_source_barcode_product(self): + """Scan source: scanned product has one related move line, + next step 'set_line_destination' expected on it. + """ + response = self.service.dispatch( + "scan_source", + params={"barcode": self.product_a.barcode}, + ) + move_line = self.service._find_location_move_lines( + product=self.product_a, + ) + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=10.0, + ) + + def test_scan_source_barcode_product_not_found(self): + """Scan source: scanned product has no related move line, + next step 'select_line' expected. + """ + response = self.service.dispatch( + "scan_source", + params={"barcode": self.free_product.barcode}, + ) + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.product_not_found_in_pickings(), + ) + + def test_scan_source_barcode_product_multiple_moves_different_location(self): + """Scan source: scanned product has move lines in multiple sub location. + + next step : 'select_line' expected. + + Then scan a location and a specific line is selected. + + next step : 'set_line_destination' + """ + # Using picking4 which has a product in two sublocation + response = self.service.dispatch( + "scan_source", + params={"barcode": self.product_e.barcode}, + ) + move_lines = self.service._find_location_move_lines(product=self.product_e) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + product=self.product_e, + message=self.service.msg_store.several_move_in_different_location(), + ) + response = self.service.dispatch( + "scan_source", + params={ + "barcode": self.zone_sublocation3.barcode, + "product_id": self.product_e.id, + }, + ) + self.assertEqual(response["next_state"], "set_line_destination") + move_line = self.service._find_location_move_lines( + product=self.product_e, locations=self.zone_sublocation3 + ) + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=6.0, + ) + + def test_scan_source_barcode_location_multiple_moves_different_product(self): + """Scan source: scanned location has move lines with multiple product. + + next step : 'select_line' expected. + + Then scan a product and a specific line is selected. + + next step : 'set_line_destination' + """ + # Using picking4 which has a product in two sublocation + response = self.service.dispatch( + "scan_source", + params={"barcode": self.zone_sublocation3.barcode}, + ) + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation3 + ) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + sublocation=self.zone_sublocation3, + message=self.service.msg_store.several_products_in_location( + self.zone_sublocation3 + ), + location_first=False, + ) + response = self.service.dispatch( + "scan_source", + params={ + "barcode": self.product_e.barcode, + "sublocation_id": self.zone_sublocation3.id, + }, + ) + self.assertEqual(response["next_state"], "set_line_destination") + move_line = self.service._find_location_move_lines( + product=self.product_e, locations=self.zone_sublocation3 + ) + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=6.0, + ) + + def test_scan_source_barcode_product_with_multiple_lot(self): + """Scan source: scanned product is found with mulitple lot number. + + next step : 'select_line' expected. + """ + # Product C has already one lot from test_zone_picking_base.py + # So lets add one more lot for that product in same location. + pick = self._create_picking(lines=[(self.product_c, 10)]) + self._fill_stock_for_moves( + pick.move_ids, in_lot=True, location=self.zone_sublocation2 + ) + pick.action_assign() + response = self.service.dispatch( + "scan_source", + params={"barcode": self.product_c.barcode}, + ) + move_lines = self.service._find_location_move_lines(product=self.product_c) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + product=self.product_c, + message=self.service.msg_store.several_move_with_different_lot(), + ) + + def test_scan_source_barcode_lot(self): + """Scan source: scanned lot has one related move line, + next step 'set_line_destination' expected on it. + """ + # Product C has already one lot from test_zone_picking_base.py + # So lets add one more lot for that product in a different location. + pick = self._create_picking(lines=[(self.product_c, 10)]) + self._fill_stock_for_moves( + pick.move_ids, in_lot=True, location=self.zone_sublocation1 + ) + pick.action_assign() + lot = self.picking2.move_line_ids.lot_id[0] + response = self.service.dispatch( + "scan_source", + params={"barcode": lot.name}, + ) + move_lines = self.service._find_location_move_lines(lot=lot) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + move_line = move_lines[0] + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=10.0, + ) + + def test_scan_source_barcode_lot_in_multiple_location(self): + """Scan source: scanned lot is in multiple location + next step 'select_line' expected on it. + """ + # Picking 2 has already some lot from test_zone_picking_base.py + # So lets add in the same lot the same product in another location. + lot = self.picking2.move_line_ids.lot_id[0] + picking = self._create_picking(lines=[(lot.product_id, 2)]) + self._fill_stock_for_moves( + picking.move_ids, in_lot=lot, location=self.zone_sublocation3 + ) + picking.action_assign() + response = self.service.dispatch( + "scan_source", + params={"barcode": lot.name}, + ) + # FIX ME: need to filter lines only on lot scanned !! + move_lines = self.service._find_location_move_lines() + # move_lines = self.service._find_location_move_lines(lot=lot) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.several_move_in_different_location(), + ) + + def test_scan_source_barcode_lot_not_found(self): + """Scan source: scanned lot has no related move line, + next step 'select_line' expected. + """ + response = self.service.dispatch( + "scan_source", + params={"barcode": self.free_lot.name}, + ) + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.lot_not_found_in_pickings(), + ) + + def test_scan_source_barcode_not_found(self): + response = self.service.dispatch( + "scan_source", + params={ + "zone_location_id": self.zone_location.id, + "picking_type_id": self.picking_type.id, + "barcode": "UNKNOWN", + }, + ) + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + message=self.service.msg_store.barcode_not_found(), + ) + + def test_scan_source_multi_users(self): + """First user scans the source location 'Zone sub-location 1' containing + only one move line, then processes the next step 'set_line_destination'. + + The second user scans the same source location, and should not find any line. + """ + # The first user starts to process the only line available + # - scan source + response = self.service.scan_source( + self.zone_sublocation1.barcode, + ) + move_line = self.picking1.move_line_ids + self.assertEqual(response["next_state"], "set_line_destination") + # - set destination + self.service.set_destination( + move_line.id, + self.free_package.name, + move_line.reserved_uom_qty, + ) + self.assertEqual(move_line.shopfloor_user_id, self.env.user) + # The second user scans the same source location + env = self.env(user=self.stock_user2) + with self.work_on_services( + env=env, + menu=self.menu, + profile=self.profile, + current_zone_location=self.zone_location, + current_picking_type=self.picking_type, + ) as work: + service = work.component(usage="zone_picking") + response = service.scan_source( + self.zone_sublocation1.barcode, + ) + self.assertEqual(response["next_state"], "select_line") + self.assertEqual( + response["message"], + self.service.msg_store.wrong_record(self.zone_sublocation1), + ) + + def test_prepare_unload_buffer_empty(self): + # unload goods + response = self.service.dispatch( + "prepare_unload", + params={}, + ) + # check response + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + self.zone_location, + self.picking_type, + move_lines, + ) + + def test_prepare_unload_buffer_one_line(self): + # scan a destination package to get something in the buffer + move_line = self.picking1.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.reserved_uom_qty, + }, + ) + # unload goods + response = self.service.dispatch( + "prepare_unload", + params={}, + ) + # check response + self.assert_response_unload_set_destination( + response, + self.zone_location, + self.picking_type, + move_line, + ) + + def test_prepare_unload_buffer_multi_line_same_destination(self): + # scan a destination package for some move lines + # to get several lines in the buffer (which have the same destination) + self.another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + move_lines = self.picking5.move_line_ids + self.assertEqual(move_lines.location_dest_id, self.packing_location) + for move_line, package_dest in zip( + move_lines, self.free_package | self.another_package + ): + self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": package_dest.name, + "quantity": move_line.reserved_uom_qty, + }, + ) + # unload goods + response = self.service.dispatch( + "prepare_unload", + params={}, + ) + # check response + self.assert_response_unload_all( + response, + self.zone_location, + self.picking_type, + move_lines, + ) + + def test_list_move_lines_empty_location(self): + response = self.service.dispatch( + "list_move_lines", + params={"order": "location"}, + ) + # TODO: order by location? + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + self.zone_location, + self.picking_type, + move_lines, + ) + data_move_lines = response["data"]["select_line"]["move_lines"] + # Check that the move line in "Zone sub-location 1" is about to empty + # its location if we process it + data_move_line = [ + m + for m in data_move_lines + if m["location_src"]["barcode"] == "ZONE_SUBLOCATION_1" + ][0] + self.assertTrue(data_move_line["location_will_be_empty"]) + # Same check with the internal method + move_line = self.env["stock.move.line"].browse(data_move_line["id"]) + location_src = move_line.location_id + move_line_will_empty_location = location_src.planned_qty_in_location_is_empty( + move_lines=move_line + ) + self.assertTrue(move_line_will_empty_location) + # But if we check the location without giving the move line as parameter, + # knowing that this move line hasn't its 'qty_done' field filled, + # the location won't be considered empty with such pending move line + move_line_will_empty_location = location_src.planned_qty_in_location_is_empty() + self.assertFalse(move_line_will_empty_location) diff --git a/shopfloor/tests/test_zone_picking_select_line_first_scan_location.py b/shopfloor/tests/test_zone_picking_select_line_first_scan_location.py new file mode 100644 index 0000000000..a83953429c --- /dev/null +++ b/shopfloor/tests/test_zone_picking_select_line_first_scan_location.py @@ -0,0 +1,207 @@ +# Copyright 2023 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_zone_picking_base import ZonePickingCommonCase + +# pylint: disable=missing-return + + +class ZonePickingSelectLineFirstScanLocationCase(ZonePickingCommonCase): + """Tests for endpoint used from select_line with option 'First scan location' + + * /scan_source + + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + self.menu.sudo().scan_location_or_pack_first = True + + def test_scan_source_first_the_product_not_ok(self): + """Check first scanning a product barcode is not allowed.""" + response = self.service.dispatch( + "scan_source", + params={"barcode": self.product_a.barcode}, + ) + move_lines = self.service._find_location_move_lines( + locations=self.zone_location, + package=False, + ) + self.assertTrue(response.get("data").get("select_line")) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + message=self.service.msg_store.barcode_not_found(), + move_lines=move_lines, + location_first=True, + ) + + def test_scan_source_scan_location_then_product_ok(self): + """Check scanning location and then product is fine.""" + # Have the same product multiple time in sublocation 2 + pickingA = self._create_picking( + lines=[(self.product_b, 12), (self.product_c, 13)] + ) + self._fill_stock_for_moves( + pickingA.move_ids, in_lot=True, location=self.zone_sublocation2 + ) + pickingA.action_assign() + # Scan product B, send sublocation to simulate a previous scan for a location + response = self.service.dispatch( + "scan_source", + params={ + "barcode": self.product_b.barcode, + "sublocation_id": self.zone_sublocation2.id, + }, + ) + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation2, product=self.product_b + ) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + message=self.service.msg_store.several_move_with_different_lot(), + move_lines=move_lines, + product=self.product_b, + sublocation=self.zone_sublocation2, + location_first=True, + ) + + def test_scan_source_can_not_select_line_with_package(self): + """Do not allow to scan product with package without scanning pack first.""" + # Picking 1 with one product in a package is already in sub location 1 + pickingA = self._create_picking(lines=[(self.product_a, 13)]) + self._fill_stock_for_moves( + pickingA.move_ids[0], in_package=True, location=self.zone_sublocation1 + ) + pickingA.action_assign() + # Scanning a product after having scan a location (sublocation_id) + response = self.service.dispatch( + "scan_source", + params={ + "barcode": self.product_a.barcode, + "sublocation_id": self.zone_sublocation1.id, + }, + ) + self.assertEqual(response["next_state"], "select_line") + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation1, package=False, product=self.product_a + ) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + sublocation=self.zone_sublocation1, + picking_type=self.picking_type, + message=self.service.msg_store.product_not_found_in_pickings(), + move_lines=move_lines, + location_first=True, + ) + + def test_scan_source_scan_location_no_lines_without_package(self): + """Check list of lines when first scanning location. + + With the scan_location_or_pack_first option on. When scanning first the + location and no lines without package exist, ask to scan a package. + + """ + # Picking 1 with one product in a package is already in sub location 1 + pickingA = self._create_picking(lines=[(self.product_a, 13)]) + self._fill_stock_for_moves( + pickingA.move_ids[0], in_package=True, location=self.zone_sublocation1 + ) + pickingA.action_assign() + response = self.service.dispatch( + "scan_source", + params={ + "barcode": self.zone_sublocation1.barcode, + }, + ) + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation1, package=False + ) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + sublocation=self.zone_sublocation1, + picking_type=self.picking_type, + message=self.service.msg_store.several_packs_in_location( + self.zone_sublocation1 + ), + move_lines=move_lines, + location_first=True, + ) + + def test_scan_source_scan_package_first_with_two_product(self): + """Scan a package with two product and then scan a product.""" + pickingA = self._create_picking( + lines=[(self.product_a, 13), (self.product_b, 5)] + ) + self._fill_stock_for_moves( + pickingA.move_ids, in_package=True, location=self.zone_sublocation1 + ) + pickingA.action_assign() + package = pickingA.package_level_ids[0].package_id + response = self.service.dispatch( + "scan_source", + params={ + "barcode": package.name, + }, + ) + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation1, package=package + ) + self.assertTrue(len(move_lines), 2) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + message=self.service.msg_store.several_products_in_package(package), + move_lines=move_lines, + location_first=True, + package=package, + ) + response = self.service.dispatch( + "scan_source", + params={"barcode": self.product_a.barcode, "package_id": package.id}, + ) + move_line = pickingA.move_line_ids[0] + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=13.0, + ) + + def test_scan_source_scan_product_message_product_has_package(self): + """""" + # Picking 1 with one product in a package is already in sub location 1 + pickingA = self._create_picking(lines=[(self.product_a, 13)]) + self._fill_stock_for_moves( + pickingA.move_ids[0], in_package=True, location=self.zone_sublocation1 + ) + pickingA.action_assign() + # Scanning a product after having scan a location (sublocation_id) + response = self.service.dispatch( + "scan_source", + params={ + "barcode": self.product_a.barcode, + "sublocation_id": self.zone_sublocation1.id, + }, + ) + self.assertEqual(response["next_state"], "select_line") + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation1, package=False, product=self.product_a + ) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + sublocation=self.zone_sublocation1, + picking_type=self.picking_type, + message=self.service.msg_store.product_not_found_in_pickings(), + move_lines=move_lines, + location_first=True, + ) diff --git a/shopfloor/tests/test_zone_picking_select_line_first_scan_location.py.bak b/shopfloor/tests/test_zone_picking_select_line_first_scan_location.py.bak new file mode 100644 index 0000000000..25696f4b36 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_select_line_first_scan_location.py.bak @@ -0,0 +1,202 @@ +# Copyright 2023 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingSelectLineFirstScanLocationCase(ZonePickingCommonCase): + """Tests for endpoint used from select_line with option 'First scan location' + + * /scan_source + + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + self.menu.sudo().scan_location_or_pack_first = True + + def test_scan_source_first_the_product_not_ok(self): + """Check first scanning a product barcode is not allowed.""" + response = self.service.dispatch( + "scan_source", + params={"barcode": self.product_a.barcode}, + ) + move_lines = self.service._find_location_move_lines( + locations=self.zone_location, + package=False, + ) + self.assertTrue(response.get("data").get("select_line")) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + message=self.service.msg_store.barcode_not_found(), + move_lines=move_lines, + location_first=True, + ) + + def test_scan_source_scan_location_then_product_ok(self): + """Check scanning location and then product is fine.""" + # Have the same product multiple time in sublocation 2 + pickingA = self._create_picking( + lines=[(self.product_b, 12), (self.product_c, 13)] + ) + self._fill_stock_for_moves( + pickingA.move_lines, in_lot=True, location=self.zone_sublocation2 + ) + pickingA.action_assign() + # Scan product B, send sublocation to simulate a previous scan for a location + response = self.service.dispatch( + "scan_source", + params={ + "barcode": self.product_b.barcode, + "sublocation_id": self.zone_sublocation2.id, + }, + ) + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation2, product=self.product_b + ) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + message=self.service.msg_store.several_move_with_different_lot(), + move_lines=move_lines, + product=self.product_b, + sublocation=self.zone_sublocation2, + location_first=True, + ) + + def test_scan_source_can_not_select_line_with_package(self): + """Do not allow to scan product with package without scanning pack first.""" + # Picking 1 with one product in a package is already in sub location 1 + pickingA = self._create_picking(lines=[(self.product_a, 13)]) + self._fill_stock_for_moves( + pickingA.move_lines[0], in_package=True, location=self.zone_sublocation1 + ) + pickingA.action_assign() + # Scanning a product after having scan a location (sublocation_id) + response = self.service.dispatch( + "scan_source", + params={ + "barcode": self.product_a.barcode, + "sublocation_id": self.zone_sublocation1.id, + }, + ) + self.assertEqual(response["next_state"], "select_line") + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation1, package=False, product=self.product_a + ) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + sublocation=self.zone_sublocation1, + picking_type=self.picking_type, + message=self.service.msg_store.product_not_found(), + move_lines=move_lines, + location_first=True, + ) + + def test_scan_source_scan_location_no_lines_without_package(self): + """Check list of lines when first scanning location. + + With the scan_location_or_pack_first option on. When scanning first the + location and no lines without package exist, ask to scan a package. + + """ + # Picking 1 with one product in a package is already in sub location 1 + pickingA = self._create_picking(lines=[(self.product_a, 13)]) + self._fill_stock_for_moves( + pickingA.move_lines[0], in_package=True, location=self.zone_sublocation1 + ) + pickingA.action_assign() + response = self.service.dispatch( + "scan_source", + params={ + "barcode": self.zone_sublocation1.barcode, + }, + ) + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation1, package=False + ) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + sublocation=self.zone_sublocation1, + picking_type=self.picking_type, + message=self.service.msg_store.several_packs_in_location( + self.zone_sublocation1 + ), + move_lines=move_lines, + location_first=True, + ) + + def test_scan_source_scan_package_first_with_two_product(self): + """Scan a package with two product and then scan a product.""" + pickingA = self._create_picking( + lines=[(self.product_a, 13), (self.product_b, 5)] + ) + self._fill_stock_for_moves( + pickingA.move_lines, in_package=True, location=self.zone_sublocation1 + ) + pickingA.action_assign() + package = pickingA.package_level_ids[0].package_id + response = self.service.dispatch( + "scan_source", + params={ + "barcode": package.name, + }, + ) + move_lines = self.service._find_location_move_lines( + locations=self.zone_sublocation1, package=package + ) + self.assertTrue(len(move_lines), 2) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + message=self.service.msg_store.several_products_in_package(package), + move_lines=move_lines, + location_first=True, + package=package, + ) + response = self.service.dispatch( + "scan_source", + params={"barcode": self.product_a.barcode, "package_id": package.id}, + ) + move_line = pickingA.move_line_ids[0] + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=13.0, + ) + + # def test_scan_source_scan_package_first_with_one_line(self): + # pickingA = self._create_picking(lines=[(self.product_a, 13)]) + # self._fill_stock_for_moves( + # pickingA.move_lines[0], in_package=True, location=self.zone_sublocation1 + # ) + # pickingA.action_assign() + # package = pickingA.package_level_ids[0].package_id + # response = self.service.dispatch( + # "scan_source", + # params={ + # "barcode": package.name, + # }, + # ) + # move_lines = self.service._find_location_move_lines( + # locations=self.zone_sublocation1, package=package + # ) + # self.assert_response_select_line( + # response, + # zone_location=self.zone_location, + # picking_type=self.picking_type, + # # message=self.service.msg_store.location_empty_scan_package( + # # self.zone_sublocation1 + # # ), + # move_lines=move_lines, + # # location_first=True, + # ) diff --git a/shopfloor/tests/test_zone_picking_select_line_no_prefill_qty.py b/shopfloor/tests/test_zone_picking_select_line_no_prefill_qty.py new file mode 100644 index 0000000000..f3adfad0a9 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_select_line_no_prefill_qty.py @@ -0,0 +1,107 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from .test_zone_picking_base import ZonePickingCommonCase + +# pylint: disable=missing-return + + +class ZonePickingSelectLineCase(ZonePickingCommonCase): + """Tests for endpoint used from select_line with no prefill quantity + + * /scan_source + + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + self.menu.sudo().no_prefill_qty = True + + def test_scan_source_barcode_location_no_prefill(self): + """When a location is scanned, qty_done in response is False.""" + response = self.service.dispatch( + "scan_source", + params={"barcode": self.zone_sublocation1.barcode}, + ) + move_line = self.picking1.move_line_ids + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=False, + ) + + def test_scan_source_barcode_package_no_prefill(self): + """When a package is scanned, qty_done in response is False.""" + package = self.picking1.package_level_ids[0].package_id + response = self.service.dispatch( + "scan_source", + params={"barcode": package.name}, + ) + move_lines = self.service._find_location_move_lines( + package=package, + ) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + move_line = move_lines[0] + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=False, + ) + + def test_scan_source_barcode_product_no_prefill(self): + """When a product is scanned, qty_done in response is 1.0.""" + response = self.service.dispatch( + "scan_source", + params={"barcode": self.product_a.barcode}, + ) + move_line = self.service._find_location_move_lines( + product=self.product_a, + ) + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=1.0, + ) + + def test_scan_source_barcode_lot_no_prefill(self): + """When a lot is scanned, qty_done in response is 1.0""" + lot = self.picking2.move_line_ids.lot_id[0] + response = self.service.dispatch( + "scan_source", + params={"barcode": lot.name}, + ) + move_lines = self.service._find_location_move_lines(lot=lot) + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + move_line = move_lines[0] + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=1.0, + ) + + def test_scan_source_barcode_packaging_no_prefill(self): + """Check qty_done for packaginge scan with no_prefill.""" + packaging = self.product_a_packaging + response = self.service.dispatch( + "scan_source", + params={"barcode": packaging.barcode}, + ) + move_line = self.service._find_location_move_lines( + product=self.product_a, + ) + self.assert_response_set_line_destination( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_line=move_line, + qty_done=packaging.qty, + ) diff --git a/shopfloor/tests/test_zone_picking_select_picking_type.py b/shopfloor/tests/test_zone_picking_select_picking_type.py new file mode 100644 index 0000000000..ef50bea0f1 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_select_picking_type.py @@ -0,0 +1,26 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + + +class ZonePickingSelectPickingTypeCase(ZonePickingCommonCase): + """Tests for endpoint used from select_picking_type + + * /list_move_lines + + """ + + def test_list_move_lines_ok(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "list_move_lines", + params={}, + ) + move_lines = self.service._find_location_move_lines(zone_location, picking_type) + self.assert_response_select_line( + response, + zone_location=self.zone_location, + picking_type=self.picking_type, + move_lines=move_lines, + ) diff --git a/shopfloor/tests/test_zone_picking_set_line_destination.py b/shopfloor/tests/test_zone_picking_set_line_destination.py new file mode 100644 index 0000000000..07bb833a4c --- /dev/null +++ b/shopfloor/tests/test_zone_picking_set_line_destination.py @@ -0,0 +1,643 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + +# pylint: disable=missing-return + + +class ZonePickingSetLineDestinationCase(ZonePickingCommonCase): + """Tests for endpoint used from set_line_destination + + * /set_destination + + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + + def test_set_destination_wrong_parameters(self): + move_line = self.picking1.move_line_ids[0] + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": 1234567890, + "barcode": self.packing_location.barcode, + "quantity": move_line.reserved_uom_qty, + "confirmation": False, + }, + ) + self.assert_response_start( + response, + message=self.service.msg_store.record_not_found(), + ) + + def test_set_destination_location_confirm(self): + """Scanned barcode is the destination location but needs confirmation + as it is outside the current move line destination but is still + allowed by the picking type's default destination. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + move_line.location_dest_id = self.shelf1 + quantity_done = move_line.reserved_uom_qty + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": quantity_done, + "confirmation": False, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.confirm_location_changed( + move_line.location_dest_id, self.packing_location + ), + confirmation_required=True, + qty_done=quantity_done, + ) + # Confirm the destination with a wrong destination (should not happen) + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.customer_location.barcode, + "quantity": move_line.reserved_uom_qty, + "confirmation": True, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + qty_done=quantity_done, + ) + # Confirm the destination with the right destination this time + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.reserved_uom_qty, + "confirmation": True, + }, + ) + # Check response + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), + ) + + def test_set_destination_location_move_invalid_location(self): + # Confirm the destination with a wrong destination, outside of picking + # and move's move line (should not happen) + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + move_line.move_id.location_dest_id = self.packing_sublocation_a + move_line.picking_id.location_dest_id = self.packing_sublocation_a + quantity_done = move_line.reserved_uom_qty + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_sublocation_b.barcode, + "quantity": quantity_done, + "confirmation": True, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + qty_done=quantity_done, + ) + + def test_set_destination_location_no_other_move_line_full_qty(self): + """Scanned barcode is the destination location. + + The move line is the only one in the move, and we move the whole qty. + + Initial data: + + move qty 10 (assigned): + -> move_line qty 10 from location X + + Then the operator move the 10 qty, we get: + + move qty 10 (done): + -> move_line qty 10 from location X + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + moves_before = self.picking1.move_ids + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.reserved_uom_qty, + "confirmation": False, + }, + ) + self.assertEqual(move_line.state, "done") + # Check picking data + moves_after = self.picking1.move_ids + self.assertEqual(moves_before, moves_after) + self.assertEqual(move_line.qty_done, 10) + # Check response + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), + ) + + def test_set_destination_location_no_other_move_line_partial_qty(self): + """Scanned barcode is the destination location. + + The move line is the only one in the move, and we move some of the qty. + + Initial data: + + move qty 10 (assigned): + -> move_line qty 10 from location X + + Then the operator move 6 qty on 10, we get: + + an error because we can move only full qty by location + and only a package barcode is allowed on scan. + """ + zone_location = self.zone_location + picking_type = self.picking3.picking_type_id + barcode = self.packing_location.barcode + moves_before = self.picking3.move_ids + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + # we need a destination package if we want to scan a destination location + move_line.result_package_id = self.free_package + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": barcode, + "quantity": 6, + "confirmation": False, + }, + ) + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + qty_done=6, + message=self.service.msg_store.package_not_found_for_barcode(barcode), + ) + + def test_set_destination_location_several_move_line_full_qty(self): + """Scanned barcode is the destination location. + + The move line has siblings in the move, and we move the whole qty: + the processed move line will then get its own move (split from original one) + + Initial data: + + move qty 10 (assigned): + -> move_line qty 6 from location X + -> move_line qty 4 from location Y + + Then the operator move 6 qty (from the first move line), we get: + + move qty 6 (done): + -> move_line qty 4 from location X + move qty 4 (assigned): + -> move_line qty 4 from location Y (untouched) + """ + zone_location = self.zone_location + picking_type = self.picking4.picking_type_id + moves_before = self.picking4.move_ids + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 2) + move_line = moves_before.move_line_ids[0] + # we need a destination package if we want to scan a destination location + move_line.result_package_id = self.free_package + other_move_line = moves_before.move_line_ids[1] + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.reserved_uom_qty, # 6 qty + "confirmation": False, + }, + ) + self.assertEqual(move_line.state, "done") + # Check picking data (move has been split in two, 6 done and 4 remaining) + + done_picking = self.picking4.backorder_ids + self.assertEqual(done_picking.state, "done") + self.assertEqual(self.picking4.state, "assigned") + move_after = self.picking4.move_ids + self.assertEqual(len(move_after), 1) + self.assertEqual(move_line.move_id.product_uom_qty, 6) + self.assertEqual(move_line.move_id.state, "done") + self.assertEqual(move_line.move_id.move_line_ids.reserved_uom_qty, 0) + self.assertEqual(move_after.product_uom_qty, 4) + self.assertEqual(move_after.state, "assigned") + self.assertEqual(move_after.move_line_ids.reserved_uom_qty, 4) + self.assertEqual(move_line.qty_done, 6) + self.assertNotEqual(move_line.move_id, other_move_line.move_id) + # Check response + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), + ) + + def test_set_destination_location_several_move_line_partial_qty(self): + """Scanned barcode is the destination location. + + The move line has siblings in the move, and we move some of the qty: + the processed move line will then get its own move (split from original one) + + Initial data: + + move qty 10 (assigned): + -> move_line qty 6 from location X + -> move_line qty 4 from location Y + + Then the operator move 4 qty on 6 (from the first move line), we get: + + an error because we can move only full qty by location + and only a package barcode is allowed on scan. + """ + zone_location = self.zone_location + picking_type = self.picking4.picking_type_id + barcode = self.packing_location.barcode + moves_before = self.picking4.move_ids + self.assertEqual(len(moves_before), 1) # 10 qty + self.assertEqual(len(moves_before.move_line_ids), 2) # 6+4 qty + move_line = moves_before.move_line_ids[0] + # we need a destination package if we want to scan a destination location + move_line.result_package_id = self.free_package + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": barcode, + "quantity": 4, # 4/6 qty + "confirmation": False, + }, + ) + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + qty_done=4, + message=self.service.msg_store.package_not_found_for_barcode(barcode), + ) + + def test_set_destination_location_zero_check(self): + """Scanned barcode is the destination location. + + The move line is the only one in the source location, as such the + 'zero_check' step is triggered. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + picking_type.sudo().shopfloor_zero_check = True + self.assertEqual(len(self.picking1.move_line_ids), 1) + move_line = self.picking1.move_line_ids + location_is_empty = move_line.location_id.planned_qty_in_location_is_empty + self.assertFalse(location_is_empty()) + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.reserved_uom_qty, + "confirmation": False, + }, + ) + self.assertTrue(location_is_empty()) + # Check response + self.assert_response_zero_check( + response, zone_location, picking_type, move_line + ) + + def test_set_destination_package_full_qty(self): + """Scanned barcode is the destination package. + + Initial data: + + move qty 10 (assigned): + -> move_line qty 10 from location X + + Then the operator move the 10 qty, we get: + + move qty 10 (done): + -> move_line qty 10 from location X with the scanned package + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + moves_before = self.picking1.move_ids + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.reserved_uom_qty, + "confirmation": False, + }, + ) + # Check picking data + moves_after = self.picking1.move_ids + self.assertEqual(moves_before, moves_after) + self.assertRecordValues( + move_line, + [ + { + "result_package_id": self.free_package.id, + "reserved_uom_qty": 10, + "qty_done": 10, + "shopfloor_user_id": self.env.user.id, + }, + ], + ) + # Check response + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), + ) + + def test_set_destination_package_partial_qty(self): + """Scanned barcode is the destination package. + + Initial data: + + move qty 10 (assigned): + -> move_line qty 10 from location X + + Then the operator move the 6 on 10 qty, we get: + + move qty 6 (assigned): + -> move_line qty 6 from location X with the scanned package (buffer) + -> move_line qty 4 from location X (remaining) + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + moves_before = self.picking1.move_ids + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": 6, + "confirmation": False, + }, + ) + # Check picking data + moves_after = self.picking1.move_ids + new_move_line = self.picking1.move_line_ids.filtered( + lambda line: line != move_line + ) + self.assertTrue(move_line != new_move_line) + self.assertEqual(moves_before, moves_after) + self.assertRecordValues( + move_line, + [ + { + "result_package_id": self.free_package.id, + "reserved_uom_qty": 6, + "qty_done": 6, + "shopfloor_user_id": self.env.user.id, + }, + ], + ) + self.assertRecordValues( + new_move_line, + [ + { + "result_package_id": new_move_line.package_id.id, # Unchanged + "reserved_uom_qty": 4, + "qty_done": 0, + "shopfloor_user_id": False, + }, + ], + ) + # Check response + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), + ) + + def test_set_destination_package_zero_check(self): + """Scanned barcode is the destination package. + + The move line is the only one in the source location, as such the + 'zero_check' step is triggered. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + picking_type.sudo().shopfloor_zero_check = True + self.assertEqual(len(self.picking1.move_line_ids), 1) + move_line = self.picking1.move_line_ids + location_is_empty = move_line.location_id.planned_qty_in_location_is_empty + self.assertFalse(location_is_empty()) + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.reserved_uom_qty, + "confirmation": False, + }, + ) + self.assertTrue(location_is_empty()) + # Check response + self.assert_response_zero_check( + response, + zone_location, + picking_type, + move_line, + ) + + def test_set_same_destination_package_multiple_moves(self): + """Scanned barcode is the destination package.""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + # picking_type.sudo().shopfloor_zero_check = True + self.assertEqual(len(self.picking1.move_line_ids), 1) + move_line = self.picking1.move_line_ids + location_is_empty = move_line.location_id.planned_qty_in_location_is_empty + self.assertFalse(location_is_empty()) + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.reserved_uom_qty, + "confirmation": False, + }, + ) + self.assertTrue(location_is_empty()) + # Check response + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), + ) + # Now, try to add more goods in the same package + move_line = self.picking3.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.reserved_uom_qty, + "confirmation": False, + }, + ) + self.assertEqual( + response["message"], + { + "body": "Package FREE_PACKAGE is already used.", + "message_type": "warning", + }, + ) + # Now enable `multiple_move_single_pack` and try again + self.menu.sudo().write( + { + "multiple_move_single_pack": True, + "unload_package_at_destination": True, + } + ) + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.reserved_uom_qty, + "confirmation": False, + }, + ) + # We now have no error in the response + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), + ) + + def test_set_destination_location_zero_quantity(self): + """Scanned barcode is the destination location. + + Quantity to move is zero -> error raised, user can try again. + + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_ids.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": 0, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.picking_zero_quantity(), + qty_done=move_line.reserved_uom_qty, + ) + + def test_set_destination_error_concurent_work(self): + """Scanned barcode is the destination package. + + Move line is already being worked on by someone else + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + picking_type.sudo().shopfloor_zero_check = True + self.assertEqual(len(self.picking1.move_line_ids), 1) + move_line = self.picking1.move_line_ids + move_line.picking_id.user_id = self.shopfloor_manager + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.reserved_uom_qty, + "confirmation": False, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message={ + "message_type": "error", + "body": "Someone is already working on these transfers", + }, + qty_done=move_line.reserved_uom_qty, + ) diff --git a/shopfloor/tests/test_zone_picking_set_line_destination_no_prefill_qty.py b/shopfloor/tests/test_zone_picking_set_line_destination_no_prefill_qty.py new file mode 100644 index 0000000000..5c4b1cb1e2 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_set_line_destination_no_prefill_qty.py @@ -0,0 +1,146 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + + +# pylint: disable=missing-return +class ZonePickingSetLineDestinationNoPrefillQtyCase(ZonePickingCommonCase): + """Tests for endpoint used from set_line_destination + + With the no prefill quantity option set + + * /set_destination + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking2.picking_type_id + self.menu.sudo().no_prefill_qty = True + + def test_set_destination_increment_with_product(self): + """Check increment quantity by scanning the product.""" + picking_type = self.picking2.picking_type_id + move_line = self.picking2.move_line_ids[0] + # Scan twice the product in a row to increment the quantity + for qty_done in range(2): + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": move_line.product_id.barcode, + "quantity": qty_done, + }, + ) + qty_done += 1 + # Check response + self.assert_response_set_line_destination( + response, + self.zone_location, + picking_type, + move_line, + qty_done=qty_done, + ) + + def test_set_destination_increment_with_wrong_package(self): + """Check scanning wrong package incremented quantity is not lost.""" + wrong_package = self.picking1.move_line_ids.package_id + picking_type = self.picking2.picking_type_id + move_line = self.picking2.move_line_ids[0] + # Simulate the product has been scanned twice + qty_done = 2 + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": wrong_package.name, + "quantity": qty_done, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + self.zone_location, + picking_type, + move_line, + qty_done=qty_done, + message={ + "body": "Package {} is not empty.".format(wrong_package.name), + "message_type": "warning", + }, + ) + + def test_set_destination_increment_with_wrong_product(self): + """Check increment quantity by scanning the wrong product.""" + picking_type = self.picking2.picking_type_id + move_line = self.picking2.move_line_ids[0] + # Scan twice the product in a row to increment the quantity + qty_done = 2 + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.product_a.barcode, + "quantity": qty_done, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + self.zone_location, + picking_type, + move_line, + qty_done=qty_done, + message={"body": "The package A doesn't exist", "message_type": "error"}, + ) + + def test_set_destination_increment_with_lot(self): + """Check increment quantity by scanning the lot.""" + picking_type = self.picking2.picking_type_id + move_line = self.picking2.move_line_ids.filtered("lot_id")[0] + # Scan twice the lot in a row to increment the quantity + for qty_done in range(2): + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": move_line.lot_id.name, + "quantity": qty_done, + }, + ) + qty_done += 1 + # Check response + self.assert_response_set_line_destination( + response, + self.zone_location, + picking_type, + move_line, + qty_done=qty_done, + ) + + def test_set_destination_location_zero_quantity(self): + """Scanned barcode is the destination location. + + Quantity to move is zero -> error raised, user can try again. + + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_ids.move_line_ids + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": 0, + }, + ) + # Check response + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.picking_zero_quantity(), + # And that the quantity done is zero + qty_done=0, + ) diff --git a/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py b/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py new file mode 100644 index 0000000000..a931220da7 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py @@ -0,0 +1,241 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo_test_helper import FakeModelLoader + +from .test_zone_picking_base import ZonePickingCommonCase + + +# pylint: disable=missing-return +class ZonePickingSetLineDestinationPickPackCase(ZonePickingCommonCase): + """Tests set_line_destination when `pick_pack_same_time` is one + + * /set_destination + + """ + + @classmethod + def setUpClass(cls): + try: + super().setUpClass() + except BaseException: + # ensure that the registry is restored in case of error in setUpClass + # since tearDownClass is not called in this case and our _load_test_models + # loads fake models + if hasattr(cls, "loader"): + cls.loader.restore_registry() + raise + + @classmethod + def _load_test_models(cls): + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .models import DeliveryCarrierTest, StockPackageType + + cls.loader.update_registry((DeliveryCarrierTest, StockPackageType)) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls._load_test_models() + cls.carrier = cls.env["delivery.carrier"].search([], limit=1) + delivery_packaging_type = ( + cls.env["stock.package.type"] + .sudo() + .create({"name": "TEST DEFAULT", "package_carrier_type": "test"}) + ) + cls.carrier.sudo().write( + { + "delivery_type": "test", + "integration_level": "rate", # avoid sending emails + "test_default_packaging_id": delivery_packaging_type.id, + } + ) + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + self.menu.sudo().pick_pack_same_time = True + + def test_set_destination_location_no_carrier(self): + """Scan location but carrier not set on picking""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + move_line.location_dest_id = self.shelf1 + quantity_done = move_line.reserved_uom_qty + previous_qty_done = move_line.qty_done + # Confirm the destination with the right destination + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": quantity_done, + "confirmation": True, + }, + ) + self.assertEqual(move_line.qty_done, previous_qty_done) + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.picking_without_carrier_cannot_pack( + move_line.picking_id + ), + qty_done=quantity_done, + ) + + def test_set_destination_location_ok_carrier(self): + """When carried is set goods are packed into new delivery package.""" + existing_packages = self.env["stock.quant.package"].search([]) + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + move_line.location_dest_id = self.shelf1 + move_line.picking_id.carrier_id = self.carrier + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.packing_location.barcode, + "quantity": move_line.reserved_uom_qty, + "confirmation": True, + }, + ) + # Check response + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + delivery_pkg = move_line.result_package_id + self.assertNotIn(delivery_pkg, existing_packages) + self.assertEqual( + delivery_pkg.package_type_id, self.carrier.test_default_packaging_id + ) + message = self.msg_store.confirm_pack_moved() + message["body"] += "\n" + self.msg_store.goods_packed_in(delivery_pkg)["body"] + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=message, + ) + + def test_set_destination_package_full_qty_no_carrier(self): + """Scan destination package, no carrier on picking.""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + moves_before = self.picking1.move_ids + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + quantity_done = move_line.reserved_uom_qty + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": quantity_done, + "confirmation": True, + }, + ) + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.picking_without_carrier_cannot_pack( + move_line.picking_id + ), + qty_done=quantity_done, + ) + + def test_set_destination_package_full_qty_ok_carrier_bad_package(self): + """Scan destination package, carrier on picking, package invalid.""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + moves_before = self.picking1.move_ids + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + move_line.picking_id.carrier_id = self.carrier + quantity_done = move_line.reserved_uom_qty + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": quantity_done, + "confirmation": False, + }, + ) + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.packaging_invalid_for_carrier( + self.free_package.product_packaging_id, self.carrier + ), + qty_done=quantity_done, + ) + + def test_set_destination_package_full_qty_ok_carrier_ok_package(self): + """Scan destination package, carrier on picking, package valid.""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + moves_before = self.picking1.move_ids + self.assertEqual(len(moves_before), 1) + self.assertEqual(len(moves_before.move_line_ids), 1) + move_line = moves_before.move_line_ids + move_line.picking_id.carrier_id = self.carrier + packaging = ( + self.env["product.packaging"] + .sudo() + .create( + { + "name": "TEST DEFAULT", + "package_type_id": self.carrier.test_default_packaging_id.id, + } + ) + ) + + self.free_package.product_packaging_id = packaging + response = self.service.dispatch( + "set_destination", + params={ + "move_line_id": move_line.id, + "barcode": self.free_package.name, + "quantity": move_line.reserved_uom_qty, + "confirmation": True, + }, + ) + # Check picking data + moves_after = self.picking1.move_ids + self.assertEqual(moves_before, moves_after) + self.assertRecordValues( + move_line, + [ + { + "result_package_id": self.free_package.id, + "reserved_uom_qty": 10, + "qty_done": 10, + "shopfloor_user_id": self.env.user.id, + }, + ], + ) + # Check response + move_lines = self.service._find_location_move_lines() + move_lines = move_lines.sorted(lambda l: l.move_id.priority, reverse=True) + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.confirm_pack_moved(), + ) diff --git a/shopfloor/tests/test_zone_picking_start.py b/shopfloor/tests/test_zone_picking_start.py new file mode 100644 index 0000000000..1da41dd391 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_start.py @@ -0,0 +1,206 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + +# pylint: disable=missing-return + + +class ZonePickingStartCase(ZonePickingCommonCase): + """Tests for endpoint used from start + + * /scan_location + + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + # create a picking w/ a different picking type + # which should be excluded from picking types list + bad_picking_type = cls.picking_type.sudo().copy( + { + "name": "Bad type", + "sequence_code": "WH/BAD", + "default_location_src_id": cls.zone_location.id, + "shopfloor_menu_ids": False, + } + ) + cls.extra_picking = extra_picking = cls._create_picking( + lines=[(cls.product_b, 10)], + picking_type=bad_picking_type, + ) + cls._fill_stock_for_moves( + extra_picking.move_ids, in_package=True, location=cls.zone_sublocation1 + ) + cls._update_qty_in_location(cls.zone_sublocation1, cls.product_b, 10) + extra_picking.action_assign() + + def test_data_for_zone(self): + op_type_data = self.data.picking_type(self.menu.picking_type_ids[0]) + zones_data = self.service._response_for_start()["data"]["start"]["zones"] + expected_sub1 = dict( + self.data.location(self.zone_sublocation1), + lines_count=1, + picking_count=1, + priority_lines_count=0, + priority_picking_count=0, + operation_types=[ + dict( + op_type_data, + lines_count=1, + picking_count=1, + priority_lines_count=0, + priority_picking_count=0, + ) + ], + ) + expected_sub2 = dict( + self.data.location(self.zone_sublocation2), + lines_count=2, + picking_count=2, + priority_lines_count=0, + priority_picking_count=0, + operation_types=[ + dict( + op_type_data, + lines_count=2, + picking_count=2, + priority_lines_count=0, + priority_picking_count=0, + ) + ], + ) + expected_sub3 = dict( + self.data.location(self.zone_sublocation3), + lines_count=2, + picking_count=2, + priority_lines_count=0, + priority_picking_count=0, + operation_types=[ + dict( + op_type_data, + lines_count=2, + picking_count=2, + priority_lines_count=0, + priority_picking_count=0, + ) + ], + ) + expected_sub4 = dict( + self.data.location(self.zone_sublocation4), + lines_count=3, + picking_count=3, + priority_lines_count=0, + priority_picking_count=0, + operation_types=[ + dict( + op_type_data, + lines_count=3, + picking_count=3, + priority_lines_count=0, + priority_picking_count=0, + ) + ], + ) + expected_sub5 = dict( + self.data.location(self.zone_sublocation5), + lines_count=2, + picking_count=1, + priority_lines_count=0, + priority_picking_count=0, + operation_types=[ + dict( + op_type_data, + lines_count=2, + picking_count=1, + priority_lines_count=0, + priority_picking_count=0, + ) + ], + ) + self.assertEqual( + zones_data, + [expected_sub1, expected_sub2, expected_sub3, expected_sub4, expected_sub5], + ) + + def test_select_zone(self): + """Scanned location invalid, no location found.""" + response = self.service.dispatch("select_zone") + self.assert_response_start(response) + + def test_select_zone_with_loaded_buffer(self): + """Check loaded buffer info in select zone answer.""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking5.move_line_ids[1] + # change the destination location on the move line + move_line.location_dest_id = self.zone_sublocation3 + # and set the destination package + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch("select_zone") + data = { + "zones": self.service._data_for_select_zone(zone_location.child_ids), + "buffer": { + "zone_location": self.service.data.location(zone_location), + "picking_type": self.service.data.picking_type(picking_type), + }, + } + self.assert_response( + response, + next_state="start", + data=data, + ) + + def test_scan_location_wrong_barcode(self): + """Scanned location invalid, no location found.""" + response = self.service.dispatch( + "scan_location", + params={"barcode": "UNKNOWN LOCATION"}, + ) + self.assert_response_start( + response, + message=self.service.msg_store.no_location_found(), + ) + + def test_scan_location_not_allowed(self): + """Scanned location not allowed because it's not a child of picking + types' source location. + """ + response = self.service.dispatch( + "scan_location", + params={"barcode": self.customer_location.barcode}, + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_not_allowed(), + ) + + def test_scan_location_no_move_lines(self): + """Scanned location valid, but no move lines found in it.""" + sub1_lines = self.service._find_location_move_lines(self.zone_sublocation1) + # no more lines available + sub1_lines.picking_id.action_cancel() + response = self.service.dispatch( + "scan_location", + params={"barcode": self.zone_sublocation1.barcode}, + ) + self.assert_response_start( + response, + message=self.service.msg_store.no_lines_to_process(), + ) + + def test_scan_location_ok(self): + """Scanned location valid, list of picking types of related move lines.""" + response = self.service.dispatch( + "scan_location", + params={"barcode": self.zone_location.barcode}, + ) + self.assert_response_select_picking_type( + response, + zone_location=self.zone_location, + picking_types=self.picking_type, + ) diff --git a/shopfloor/tests/test_zone_picking_stock_issue.py b/shopfloor/tests/test_zone_picking_stock_issue.py new file mode 100644 index 0000000000..084427b606 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_stock_issue.py @@ -0,0 +1,121 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + + +# pylint: disable=missing-return +class ZonePickingStockIssueCase(ZonePickingCommonCase): + """Tests for endpoint used from stock_issue + + * /stock_issue + + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + + def test_stock_issue_wrong_parameters(self): + response = self.service.dispatch( + "stock_issue", + params={"move_line_id": 1234567890}, + ) + self.assert_response_start( + response, + message=self.service.msg_store.record_not_found(), + ) + + def test_stock_issue_no_more_reservation(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + move = move_line.move_id + response = self.service.dispatch( + "stock_issue", + params={"move_line_id": move_line.id}, + ) + self.assertFalse(move_line.exists()) + self.assertFalse(move.move_line_ids) + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + ) + + def test_stock_issue1(self): + """Once the stock issue is done, the move can't be reserved anymore.""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + move = move_line.move_id + response = self.service.dispatch( + "stock_issue", + params={"move_line_id": move_line.id}, + ) + self.assertFalse(move_line.exists()) + self.assertFalse(move.move_line_ids) + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + ) + + def test_stock_issue2(self): + """Once the stock issue is done, the move has been reserved again.""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + location = move_line.location_id + move = move_line.move_id + quants_before = self.env["stock.quant"].search( + [("location_id", "=", location.id), ("product_id", "=", move.product_id.id)] + ) + # Increase the quantity in the current location + self._update_qty_in_location(location, move.product_id, 100) + response = self.service.dispatch( + "stock_issue", + params={"move_line_id": move_line.id}, + ) + self.assertFalse(move_line.exists()) + self.assertTrue(move.move_line_ids) + self.assertEqual(move.move_line_ids.location_id, location) + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move.move_line_ids, + ) + # Check the inventory + quants_after = self.env["stock.quant"].search( + [("location_id", "=", location.id), ("product_id", "=", move.product_id.id)] + ) + inventory_quant = quants_after - quants_before + self.assertTrue(inventory_quant) + + def test_stock_issue3(self): + """Once the stock issue is done, the move has been reserved again + but from another location. + """ + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + move = move_line.move_id + # Put some quantity in another location to get a new reservations from there + self._update_qty_in_location(self.zone_sublocation2, move.product_id, 10) + response = self.service.dispatch( + "stock_issue", + params={"move_line_id": move_line.id}, + ) + self.assertFalse(move_line.exists()) + self.assertTrue(move.move_line_ids) + self.assertEqual(move.move_line_ids.location_id, self.zone_sublocation2) + self.assert_response_set_line_destination( + response, + zone_location, + picking_type, + move.move_line_ids, + ) diff --git a/shopfloor/tests/test_zone_picking_unload_all.py b/shopfloor/tests/test_zone_picking_unload_all.py new file mode 100644 index 0000000000..a5a08c9efa --- /dev/null +++ b/shopfloor/tests/test_zone_picking_unload_all.py @@ -0,0 +1,353 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + + +# pylint: disable=missing-return +class ZonePickingUnloadAllCase(ZonePickingCommonCase): + """Tests for endpoint used from unload_all + + * /set_destination_all + * /unload_split + + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + + def test_set_destination_all_different_destination(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line1 = self.picking5.move_line_ids[0] + move_line2 = self.picking5.move_line_ids[1] + another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + # change the destination location of one move line + move_line2.location_dest_id = self.zone_sublocation3 + # set the destination package on lines + self.service._set_destination_package( + move_line1, + move_line1.reserved_uom_qty, + self.free_package, + ) + self.service._set_destination_package( + move_line2, + move_line2.reserved_uom_qty, + another_package, + ) + # set destination location for all lines in the buffer + response = self.service.dispatch( + "set_destination_all", + params={"barcode": self.packing_location.barcode}, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines() + self.assert_response_unload_all( + response, + zone_location, + picking_type, + buffer_lines, + message=self.service.msg_store.lines_different_dest_location(), + ) + + def test_set_destination_all_confirm_destination(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line1 = self.picking5.move_line_ids[0] + move_line2 = self.picking5.move_line_ids[1] + another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + packing_sublocation1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation-1", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATION_1", + } + ) + ) + packing_sublocation2 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation-2", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATION_2", + } + ) + ) + # set the destination package on lines + self.service._set_destination_package( + move_line1, + move_line1.reserved_uom_qty, + self.free_package, + ) + self.service._set_destination_package( + move_line2, + move_line2.reserved_uom_qty, + another_package, + ) + # set an allowed destination location (inside the picking type default + # destination location) for all lines in the buffer with a non-expected + # one, meaning a destination which is not a child of the current buffer + # lines destination + (move_line1 | move_line2).location_dest_id = packing_sublocation1 + response = self.service.dispatch( + "set_destination_all", + params={"barcode": packing_sublocation2.barcode}, + ) + # check response: this destination needs the user confirmation + buffer_lines = self.service._find_buffer_move_lines() + self.assert_response_unload_all( + response, + zone_location, + picking_type, + buffer_lines, + message=self.service.msg_store.confirm_location_changed( + packing_sublocation1, + packing_sublocation2, + ), + confirmation_required=True, + ) + # set an allowed destination location (inside the picking type default + # destination location) for all lines in the buffer with an expected one + # meaning a destination which is a child of the current buffer lines + # destination + response = self.service.dispatch( + "set_destination_all", + params={"barcode": packing_sublocation1.barcode}, + ) + # check response: OK + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + + def test_set_destination_all_ok(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line1 = self.picking5.move_line_ids[0] + move_line2 = self.picking5.move_line_ids[1] + another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + # set the destination package on lines + self.service._set_destination_package( + move_line1, + move_line1.reserved_uom_qty, + self.free_package, + ) + self.service._set_destination_package( + move_line2, + move_line2.reserved_uom_qty, + another_package, + ) + # set destination location for all lines in the buffer + response = self.service.dispatch( + "set_destination_all", + params={"barcode": self.packing_location.barcode}, + ) + # check data + self.assertEqual(self.picking5.state, "done") + # buffer should be empty + buffer_lines = self.service._find_buffer_move_lines() + self.assertFalse(buffer_lines) + # check response + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + + def test_set_destination_all_partial_qty_done_ok(self): + zone_location = self.zone_location + picking_type = self.picking6.picking_type_id + move_g = self.picking6.move_ids.filtered( + lambda m: m.product_id == self.product_g + ) + move_h = self.picking6.move_ids.filtered( + lambda m: m.product_id == self.product_h + ) + self.assertEqual(move_g.state, "assigned") + self.assertEqual(move_h.state, "partially_available") + move_line_g = move_g.move_line_ids + move_line_h = move_h.move_line_ids + another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + # set the destination package on lines + self.service._set_destination_package( + move_line_g, + move_line_g.reserved_uom_qty, + self.free_package, + ) + self.service._set_destination_package( + move_line_h, + move_line_h.reserved_uom_qty, + another_package, # partial qty + ) + # set destination location for all lines in the buffer + response = self.service.dispatch( + "set_destination_all", + params={"barcode": self.packing_location.barcode}, + ) + # check data + # picking validated + self.assertEqual(move_line_g.state, "done") + self.assertEqual(move_line_g.picking_id.state, "done") + self.assertEqual(move_line_g.qty_done, 6) + self.assertEqual(move_line_h.state, "done") + self.assertEqual(move_line_h.picking_id.state, "done") + self.assertEqual(move_line_h.qty_done, 3) + # current picking (backorder) + backorder = (move_line_g | move_line_h).picking_id.backorder_id + self.assertEqual(backorder, self.picking6) + self.assertEqual(backorder.state, "confirmed") + self.assertEqual(backorder.move_ids.product_id, self.product_h) + self.assertEqual(backorder.move_ids.product_uom_qty, 3) + self.assertFalse(backorder.move_line_ids) + # buffer should be empty + buffer_lines = self.service._find_buffer_move_lines() + self.assertFalse(buffer_lines) + # check response + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + + def test_set_destination_all_location_not_allowed(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # set the destination package on lines + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "set_destination_all", + params={"barcode": self.customer_location.barcode}, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines() + self.assert_response_unload_all( + response, + zone_location, + picking_type, + buffer_lines, + message=self.service.msg_store.location_not_allowed(), + ) + + def test_set_destination_all_location_not_found(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # set the destination package on lines + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "set_destination_all", + params={"barcode": "UNKNOWN"}, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines() + self.assert_response_unload_all( + response, + zone_location, + picking_type, + buffer_lines, + message=self.service.msg_store.no_location_found(), + ) + + def test_unload_split_buffer_empty(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "unload_split", + params={}, + ) + # check response + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + + def test_unload_split_buffer_one_line(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # put one line in the buffer + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_split", + params={}, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines() + self.assert_response_unload_set_destination( + response, + zone_location, + picking_type, + buffer_lines, + ) + + def test_unload_split_buffer_multi_lines(self): + zone_location = self.zone_location + picking_type = self.picking5.picking_type_id + move_line = self.picking5.move_line_ids + # put several lines in the buffer + self.another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + for move_line, package_dest in zip( + self.picking5.move_line_ids, self.free_package | self.another_package + ): + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + package_dest, + ) + response = self.service.dispatch( + "unload_split", + params={}, + ) + # check response + buffer_lines = self.service._find_buffer_move_lines() + completion_info = self.service._actions_for("completion.info") + completion_info_popup = completion_info.popup(buffer_lines) + self.assert_response_unload_single( + response, + zone_location, + picking_type, + buffer_lines[0], + popup=completion_info_popup, + ) diff --git a/shopfloor/tests/test_zone_picking_unload_buffer_lines.py b/shopfloor/tests/test_zone_picking_unload_buffer_lines.py new file mode 100644 index 0000000000..9b7e48014f --- /dev/null +++ b/shopfloor/tests/test_zone_picking_unload_buffer_lines.py @@ -0,0 +1,113 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + + +# pylint: disable=missing-return +class ZonePickingUnloadBufferLinesCase(ZonePickingCommonCase): + """Tests buffer lines to unload are retrieved properly. + + Buffer lines are the lines processed during zone picking work. + At the end of her/his work, the user can unload all processed lines + in one or more destination. + + Here we make sure all the lines are processable. + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + + def test_find_buffer_lines1(self): + move_lines = ( + self.picking1.move_line_ids + | self.picking2.move_line_ids + | self.picking3.move_line_ids + | self.picking4.move_line_ids + ) + zones = move_lines.mapped("location_id") + # we work on lines coming from 4 different locations + self.assertEqual(len(zones), 4) + # Process them all (simulate) + for i, line in enumerate(move_lines): + dest_package = self.env["stock.quant.package"].create( + {"name": f"TEST PKG {i}"} + ) + self.service._set_destination_package( + line, + line.reserved_uom_qty, + dest_package, + ) + + # We can unload all the lines no matter which zone we are before unload + for zone in zones: + self.service.work.current_zone_location = zone + self.assertEqual(self.service._find_buffer_move_lines(), move_lines) + + def test_find_buffer_lines2(self): + # Skip lines from picking1 + move_lines = ( + self.picking2.move_line_ids + | self.picking3.move_line_ids + | self.picking4.move_line_ids + ) + zones = move_lines.mapped("location_id") + # we work on lines coming from 3 different locations + self.assertEqual(len(zones), 3) + # Process them all (simulate) + for i, line in enumerate(move_lines): + dest_package = self.env["stock.quant.package"].create( + {"name": f"TEST PKG {i}"} + ) + self.service._set_destination_package( + line, + line.reserved_uom_qty, + dest_package, + ) + + # We can unload all the lines no matter which zone we are before unload + for zone in zones: + self.service.work.current_zone_location = zone + self.assertEqual(self.service._find_buffer_move_lines(), move_lines) + self.assertNotIn( + self.picking1.move_line_ids, self.service._find_buffer_move_lines() + ) + + def test_find_buffer_lines3(self): + move_lines = ( + self.picking2.move_line_ids + | self.picking3.move_line_ids + | self.picking4.move_line_ids + ) + zones = move_lines.mapped("location_id") + # we work on lines coming from 4 different locations + self.assertEqual(len(zones), 3) + # Process them all (simulate) + for i, line in enumerate(move_lines): + dest_package = self.env["stock.quant.package"].create( + {"name": f"TEST PKG {i}"} + ) + self.service._set_destination_package( + line, + line.reserved_uom_qty, + dest_package, + ) + # Simulate line from picking1 processed by another user + for i, line in enumerate(self.picking1.move_line_ids): + dest_package = self.env["stock.quant.package"].create( + {"name": f"TEST PKG 1 {i}"} + ) + self.service._actions_for("stock").mark_move_line_as_picked( + line, + line.reserved_uom_qty, + dest_package, + user=self.env.ref("base.user_admin"), + ) + + # We can unload all the lines no matter which zone we are before unload + for zone in zones: + self.service.work.current_zone_location = zone + self.assertEqual(self.service._find_buffer_move_lines(), move_lines) + self.assertNotIn( + self.picking1.move_line_ids, self.service._find_buffer_move_lines() + ) diff --git a/shopfloor/tests/test_zone_picking_unload_set_destination.py b/shopfloor/tests/test_zone_picking_unload_set_destination.py new file mode 100644 index 0000000000..930535980d --- /dev/null +++ b/shopfloor/tests/test_zone_picking_unload_set_destination.py @@ -0,0 +1,374 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + +# pylint: disable=missing-return + + +class ZonePickingUnloadSetDestinationCase(ZonePickingCommonCase): + """Tests for endpoint used from unload_set_destination + + * /unload_set_destination + + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.product_z = ( + cls.env["product.product"] + .sudo() + .create( + { + "name": "Product Z", + "type": "product", + "default_code": "Z", + "barcode": "Z", + "weight": 7, + } + ) + ) + cls.picking_z = cls._create_picking(lines=[(cls.product_z, 40)]) + cls._update_qty_in_location(cls.zone_sublocation1, cls.product_z, 32) + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + + def test_unload_set_destination_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + response = self.service.dispatch( + "unload_set_destination", + params={"package_id": 1234567890, "barcode": "BARCODE"}, + ) + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.record_not_found(), + ) + + def test_unload_set_destination_no_location_found(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # set the destination package + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={"package_id": self.free_package.id, "barcode": "UNKNOWN"}, + ) + self.assert_response_unload_set_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.no_location_found(), + ) + + def test_unload_set_destination_location_not_allowed(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # set the destination package + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "package_id": self.free_package.id, + "barcode": self.customer_location.barcode, + }, + ) + self.assert_response_unload_set_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_unload_set_destination_location_move_not_allowed(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + move_line[0].move_id.location_dest_id = self.packing_sublocation_a + move_line[0].picking_id.location_dest_id = self.packing_sublocation_a + # set the destination package + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "package_id": self.free_package.id, + "barcode": self.packing_sublocation_b.barcode, + }, + ) + self.assert_response_unload_set_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + ) + # Ensure that when unload_package_at_destination is False, + # the result_package_id remains. + self.assertEqual(move_line.result_package_id, self.free_package) + + def test_unload_set_destination_unload_package_enabled(self): + move_line = self.picking1.move_line_ids + packing_sublocation1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation-1", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATIO_1", + } + ) + ) + packing_sublocation2 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation-2", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATIO_2", + } + ) + ) + # set the destination package + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + move_line.location_dest_id = packing_sublocation1 + # Enable unload_package_at_destination + self.menu.sudo().write({"unload_package_at_destination": True}) + self.service.dispatch( + "unload_set_destination", + params={ + "package_id": self.free_package.id, + "barcode": packing_sublocation2.barcode, + "confirmation": True, + }, + ) + # Response has already been tested in the test above + # result package should be False + self.assertFalse(move_line.result_package_id) + + def test_unload_set_destination_confirm_location(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + packing_sublocation1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation-1", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATIO_1", + } + ) + ) + packing_sublocation2 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation-2", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATIO_2", + } + ) + ) + # set the destination package + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + move_line.location_dest_id = packing_sublocation1 + response = self.service.dispatch( + "unload_set_destination", + params={ + "package_id": self.free_package.id, + "barcode": packing_sublocation2.barcode, + }, + ) + self.assert_response_unload_set_destination( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.confirm_location_changed( + packing_sublocation1, packing_sublocation2 + ), + confirmation_required=True, + ) + + def test_unload_set_destination_ok_buffer_empty(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + packing_sublocation = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATION", + } + ) + ) + # set the destination package + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "package_id": self.free_package.id, + "barcode": packing_sublocation.barcode, + "confirmation": True, + }, + ) + # check data + self.assertEqual(move_line.location_dest_id, packing_sublocation) + self.assertEqual(move_line.move_id.state, "done") + # check response + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + + def test_unload_set_destination_ok_buffer_not_empty(self): + zone_location = self.zone_location + picking_type = self.picking5.picking_type_id + # put several lines in the buffer + self.another_package = self.env["stock.quant.package"].create( + {"name": "ANOTHER_PACKAGE"} + ) + move_lines = self.picking5.move_line_ids + for move_line, package_dest in zip( + move_lines, self.free_package | self.another_package + ): + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + package_dest, + ) + free_package_line = move_lines.filtered( + lambda l: l.result_package_id == self.free_package + ) + another_package_line = move_lines - free_package_line + + # process 1/2 buffer line + response = self.service.dispatch( + "unload_set_destination", + params={ + "package_id": self.free_package.id, + "barcode": self.packing_location.barcode, + }, + ) + # check data + done_picking = self.picking5.backorder_ids + self.assertEqual(done_picking.state, "done") + self.assertEqual(done_picking.move_line_ids, free_package_line) + + self.assertEqual(free_package_line.location_dest_id, self.packing_location) + self.assertEqual(free_package_line.move_id.state, "done") + + self.assertEqual(self.picking5.move_line_ids, another_package_line) + + # check response + buffer_line = self.service._find_buffer_move_lines() + completion_info = self.service._actions_for("completion.info") + completion_info_popup = completion_info.popup(buffer_line) + self.assert_response_unload_single( + response, + zone_location, + picking_type, + buffer_line, + popup=completion_info_popup, + ) + + def test_unload_set_destination_partially_available_backorder(self): + zone_location = self.zone_location + picking_type = self.picking_z.picking_type_id + self.assertEqual(self.picking_z.move_ids[0].product_uom_qty, 40) + self.picking_z.action_assign() + move_line = self.picking_z.move_line_ids + self.assertEqual(move_line.reserved_uom_qty, 32) + self.assertEqual(move_line.move_id.state, "partially_available") + packing_sublocation = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Packing sublocation", + "location_id": self.packing_location.id, + "barcode": "PACKING_SUBLOCATION", + } + ) + ) + # set the destination package + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_set_destination", + params={ + "package_id": self.free_package.id, + "barcode": packing_sublocation.barcode, + "confirmation": True, + }, + ) + # check data + # move line has been moved to a new picking + # move line has been validated in the new picking + self.assertNotEqual(move_line.move_id.picking_id, self.picking_z) + backorder = move_line.move_id.picking_id.backorder_id + self.assertEqual(backorder, self.picking_z) + # the backorder contains a new line w/ the rest of the qty + # that couldn't be processed + self.assertEqual(backorder.move_ids[0].product_uom_qty, 8) + self.assertEqual(backorder.state, "confirmed") + # the line has been processed + self.assertEqual(move_line.location_dest_id, packing_sublocation) + self.assertEqual(move_line.move_id.state, "done") + # check response + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) diff --git a/shopfloor/tests/test_zone_picking_unload_single.py b/shopfloor/tests/test_zone_picking_unload_single.py new file mode 100644 index 0000000000..ac5dbc235f --- /dev/null +++ b/shopfloor/tests/test_zone_picking_unload_single.py @@ -0,0 +1,123 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + +# pylint: disable=missing-return + + +class ZonePickingUnloadSingleCase(ZonePickingCommonCase): + """Tests for endpoint used from unload_single + + * /unload_scan_pack + + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + + def test_unload_scan_pack_wrong_parameters(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # wrong package ID, and there is still a move line to unload + # => get back on 'unload_single' screen + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_scan_pack", + params={"package_id": 1234567890, "barcode": "UNKNOWN"}, + ) + completion_info = self.service._actions_for("completion.info") + completion_info_popup = completion_info.popup(move_line) + self.assert_response_unload_single( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.record_not_found(), + popup=completion_info_popup, + ) + # wrong package ID, and there is no more move line to unload from the buffer + # => get back on 'select_line' screen + move_line.write( + {"qty_done": 0, "shopfloor_user_id": False, "result_package_id": False} + ) + response = self.service.dispatch( + "unload_scan_pack", + params={"package_id": 1234567890, "barcode": "UNKNOWN"}, + ) + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + message=self.service.msg_store.buffer_complete(), + ) + # wrong package ID, and there is no more move line to process in picking type + # => get back on 'start' screen + self.pickings.move_ids._do_unreserve() + response = self.service.dispatch( + "unload_scan_pack", + params={"package_id": 1234567890, "barcode": "UNKNOWN"}, + ) + self.assert_response_start( + response, + message=self.service.msg_store.picking_type_complete(picking_type), + ) + + def test_unload_scan_pack_barcode_match(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + # set the destination package + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_scan_pack", + params={ + "package_id": move_line.result_package_id.id, + "barcode": self.free_package.name, + }, + ) + self.assert_response_unload_set_destination( + response, + zone_location, + picking_type, + move_line, + ) + + def test_unload_scan_pack_barcode_not_match(self): + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids + self.wrong_package = self.env["stock.quant.package"].create( + {"name": "WRONG_PACKAGE"} + ) + # set the destination package + self.service._set_destination_package( + move_line, + move_line.reserved_uom_qty, + self.free_package, + ) + response = self.service.dispatch( + "unload_scan_pack", + params={ + "package_id": move_line.result_package_id.id, + "barcode": self.wrong_package.name, + }, + ) + self.assert_response_unload_single( + response, + zone_location, + picking_type, + move_line, + message=self.service.msg_store.barcode_no_match(self.free_package.name), + ) diff --git a/shopfloor/tests/test_zone_picking_zero_check.py b/shopfloor/tests/test_zone_picking_zero_check.py new file mode 100644 index 0000000000..323595df72 --- /dev/null +++ b/shopfloor/tests/test_zone_picking_zero_check.py @@ -0,0 +1,43 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from .test_zone_picking_base import ZonePickingCommonCase + + +# pylint: disable=missing-return +class ZonePickingZeroCheckCase(ZonePickingCommonCase): + """Tests for endpoint used from zero_check + + * /is_zero + + """ + + def setUp(self): + super().setUp() + self.service.work.current_picking_type = self.picking1.picking_type_id + + def test_is_zero_wrong_parameters(self): + response = self.service.dispatch( + "is_zero", + params={"move_line_id": 1234567890, "zero": True}, + ) + self.assert_response_start( + response, + message=self.service.msg_store.record_not_found(), + ) + + def test_is_zero_is_empty(self): + """call /is_zero confirming it's empty""" + zone_location = self.zone_location + picking_type = self.picking1.picking_type_id + move_line = self.picking1.move_line_ids[0] + response = self.service.dispatch( + "is_zero", + params={"move_line_id": move_line.id, "zero": True}, + ) + move_lines = self.service._find_location_move_lines() + self.assert_response_select_line( + response, + zone_location, + picking_type, + move_lines, + ) diff --git a/shopfloor/utils.py b/shopfloor/utils.py new file mode 100644 index 0000000000..b5b3257604 --- /dev/null +++ b/shopfloor/utils.py @@ -0,0 +1,13 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +def to_float(val): + if isinstance(val, float): + return val + if isinstance(val, int): + return float(val) + if isinstance(val, str): + if val.replace(".", "", 1).isdigit(): + return float(val) + return None diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml new file mode 100644 index 0000000000..c70b391f68 --- /dev/null +++ b/shopfloor/views/shopfloor_menu.xml @@ -0,0 +1,167 @@ + + + + shopfloor.menu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + shopfloor.menu + + + + + + + + + shopfloor.menu + + + + + + + + diff --git a/shopfloor/views/stock_location.xml b/shopfloor/views/stock_location.xml new file mode 100644 index 0000000000..324d896479 --- /dev/null +++ b/shopfloor/views/stock_location.xml @@ -0,0 +1,20 @@ + + + + Shopfloor stock.location form + stock.location + + + + + + + + + + + + + diff --git a/shopfloor/views/stock_move_line.xml b/shopfloor/views/stock_move_line.xml new file mode 100644 index 0000000000..dd8199fa63 --- /dev/null +++ b/shopfloor/views/stock_move_line.xml @@ -0,0 +1,52 @@ + + + + shopfloor stock_move_line_detailed_operation_tree + stock.move.line + + + + + + + + + + + + + + + shopfloor stock.move.line.operations.tree + stock.move.line + + + + + + + + diff --git a/shopfloor/views/stock_picking_type.xml b/shopfloor/views/stock_picking_type.xml new file mode 100644 index 0000000000..0719b75589 --- /dev/null +++ b/shopfloor/views/stock_picking_type.xml @@ -0,0 +1,19 @@ + + + + Operation Types + stock.picking.type + + + + + + + + + + + diff --git a/test-requirements.txt b/test-requirements.txt index 4aadb0af85..689482e20d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,2 @@ vcrpy-unittest +odoo_test_helper