diff --git a/setup/shopfloor_delivery_shipment/odoo/addons/shopfloor_delivery_shipment b/setup/shopfloor_delivery_shipment/odoo/addons/shopfloor_delivery_shipment new file mode 120000 index 0000000000..4b43b9582e --- /dev/null +++ b/setup/shopfloor_delivery_shipment/odoo/addons/shopfloor_delivery_shipment @@ -0,0 +1 @@ +../../../../shopfloor_delivery_shipment \ No newline at end of file diff --git a/setup/shopfloor_delivery_shipment/setup.py b/setup/shopfloor_delivery_shipment/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopfloor_delivery_shipment/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopfloor_delivery_shipment/README.rst b/shopfloor_delivery_shipment/README.rst new file mode 100644 index 0000000000..bb99df6145 --- /dev/null +++ b/shopfloor_delivery_shipment/README.rst @@ -0,0 +1,106 @@ +========================================= +Shopfloor - Delivery with shipment advice +========================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:92f864184d8b226015d3bf530f5fe6b5380037660e0bd2db36c22839cc32b24a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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_delivery_shipment + :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_delivery_shipment + :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 scenario to manage the delivery process based on shipment advices. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Sébastien Alix + +Design +~~~~~~ + +* Joël Grand-Guillaume +* Jacques-Etienne Baudoux + +Other credits +~~~~~~~~~~~~~ + +**Financial support** + +* Cosanum +* Camptocamp 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-sebalix| image:: https://github.com/sebalix.png?size=40px + :target: https://github.com/sebalix + :alt: sebalix +.. |maintainer-TDu| image:: https://github.com/TDu.png?size=40px + :target: https://github.com/TDu + :alt: TDu + +Current `maintainers `__: + +|maintainer-sebalix| |maintainer-TDu| + +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_delivery_shipment/__init__.py b/shopfloor_delivery_shipment/__init__.py new file mode 100644 index 0000000000..044a1ab298 --- /dev/null +++ b/shopfloor_delivery_shipment/__init__.py @@ -0,0 +1,4 @@ +from . import actions +from . import models +from . import services +from .hooks import post_init_hook, uninstall_hook diff --git a/shopfloor_delivery_shipment/__manifest__.py b/shopfloor_delivery_shipment/__manifest__.py new file mode 100644 index 0000000000..ae5f3ed1cf --- /dev/null +++ b/shopfloor_delivery_shipment/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +{ + "name": "Shopfloor - Delivery with shipment advice", + "summary": "Manage delivery process with shipment advices", + "version": "16.0.1.0.0", + "development_status": "Alpha", + "category": "Inventory", + "website": "https://github.com/OCA/wms", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["sebalix", "TDu"], + "license": "AGPL-3", + "application": False, + "depends": [ + # OCA/wms + "shopfloor", + # OCA/stock-logistics-transport + "shipment_advice", + ], + "data": ["data/shopfloor_scenario_data.xml", "views/shopfloor_menu.xml"], + "demo": ["demo/shopfloor_profile_demo.xml", "demo/shopfloor_menu_demo.xml"], + "post_init_hook": "post_init_hook", + "uninstall_hook": "uninstall_hook", +} diff --git a/shopfloor_delivery_shipment/actions/__init__.py b/shopfloor_delivery_shipment/actions/__init__.py new file mode 100644 index 0000000000..8fc4040255 --- /dev/null +++ b/shopfloor_delivery_shipment/actions/__init__.py @@ -0,0 +1,4 @@ +from . import data +from . import message +from . import schema +from . import search diff --git a/shopfloor_delivery_shipment/actions/data.py b/shopfloor_delivery_shipment/actions/data.py new file mode 100644 index 0000000000..2f08c98375 --- /dev/null +++ b/shopfloor_delivery_shipment/actions/data.py @@ -0,0 +1,63 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo.addons.component.core import Component +from odoo.addons.shopfloor_base.utils import ensure_model + + +class DataAction(Component): + _inherit = "shopfloor.data.action" + + @ensure_model("shipment.advice") + def shipment_advice(self, record, **kw): + data = self._jsonify( + record.with_context(shipment_advice=record.id), + self._shipment_advice_parser, + **kw + ) + data["is_planned"] = bool(record.planned_move_ids) + return data + + def shipment_advices(self, record, **kw): + return self.shipment_advice(record, multi=True) + + @property + def _shipment_advice_parser(self): + return [ + "id", + "name", + ("dock_id:dock", self._dock_parser), + "state", + ] + + @ensure_model("stock.dock") + def dock(self, record, **kw): + return self._jsonify( + record.with_context(dock=record.id), self._dock_parser, **kw + ) + + def docks(self, record, **kw): + return self.dock(record, multi=True) + + @property + def _dock_parser(self): + return self._simple_record_parser() + + @ensure_model("stock.picking") + def picking_loaded(self, record, **kw): + return self._jsonify(record, self._picking_loaded_parser, **kw) + + def pickings_loaded(self, record, **kw): + return self.picking_loaded(record, multi=True) + + @property + def _picking_loaded_parser(self): + return self._picking_parser + [ + "loaded_progress_f", + "loaded_packages_progress_f", + "loaded_move_lines_progress_f", + "loaded_progress", + "loaded_packages_progress", + "loaded_move_lines_progress", + "is_fully_loaded_in_shipment:is_fully_loaded", + "is_partially_loaded_in_shipment:is_partially_loaded", + ] diff --git a/shopfloor_delivery_shipment/actions/message.py b/shopfloor_delivery_shipment/actions/message.py new file mode 100644 index 0000000000..114c18318b --- /dev/null +++ b/shopfloor_delivery_shipment/actions/message.py @@ -0,0 +1,210 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo import _ + +from odoo.addons.component.core import Component + + +class MessageAction(Component): + _inherit = "shopfloor.message.action" + + def no_shipment_in_progress(self): + return { + "message_type": "error", + "body": _("No shipment advice in progress found for this loading dock."), + } + + def scan_dock_again_to_confirm(self, dock): + return { + "message_type": "error", + "body": _( + "No shipment advice in progress found for this loading dock. " + "Scan again {} to create a new one." + ).format(dock.name), + } + + def picking_not_planned_in_shipment(self, picking, shipment_advice): + return { + "message_type": "error", + "body": _( + "Transfer %(picking_name)s has not been planned in the shipment " + "%(shipment_name)s." + ) + % { + "picking_name": picking.name, + "shipment_name": shipment_advice.name, + }, + } + + def package_not_planned_in_shipment(self, package, shipment_advice): + return { + "message_type": "error", + "body": _( + "Package %(package_name)s has not been planned in the shipment " + "%(shipment_name)s." + ) + % { + "package_name": package.name, + "shipment_name": shipment_advice.name, + }, + } + + def lot_not_planned_in_shipment(self, lot, shipment_advice): + return { + "message_type": "error", + "body": _( + "Lot %(lot_name)s has not been planned in the shipment " + "%(shipment_name)s." + ) + % { + "lot_name": lot.name, + "shipment_name": shipment_advice.name, + }, + } + + def product_not_planned_in_shipment(self, product, shipment_advice): + return { + "message_type": "error", + "body": _( + "Product %(product_barcode)s has not been planned in the shipment " + "%(shipment_name)s." + ) + % { + "product_barcode": product.barcode, + "shipment_name": shipment_advice.name, + }, + } + + def unable_to_load_package_in_shipment(self, package, shipment_advice): + return { + "message_type": "error", + "body": _( + "Package %(package_name)s can not been loaded in the shipment " + "%(shipment_name)s." + ) + % { + "package_name": package.name, + "shipment_name": shipment_advice.name, + }, + } + + def unable_to_load_lot_in_shipment(self, lot, shipment_advice): + return { + "message_type": "error", + "body": _( + "Lot %(lot_name)s can not been loaded in the shipment " + "%(shipment_name)s." + ) + % { + "lot_name": lot.name, + "shipment_name": shipment_advice.name, + }, + } + + def unable_to_load_product_in_shipment(self, product, shipment_advice): + return { + "message_type": "error", + "body": _( + "Product %(product_barcode)s can not been loaded in the shipment " + "%(shipment_name)s." + ) + % { + "product_barcode": product.barcode, + "shipment_name": shipment_advice.name, + }, + } + + def package_already_loaded_in_shipment(self, package, shipment_advice): + return { + "message_type": "warning", + "body": _( + "Package %(package_name)s is already loaded in the shipment " + "%(shipment_name)s." + ) + % { + "package_name": package.name, + "shipment_name": shipment_advice.name, + }, + } + + def lot_already_loaded_in_shipment(self, lot, shipment_advice): + return { + "message_type": "warning", + "body": _( + "Lot %(lot_name)s is already loaded in the shipment " + "%(shipment_name)s." + ) + % { + "lot_name": lot.name, + "shipment_name": shipment_advice.name, + }, + } + + def product_already_loaded_in_shipment(self, product, shipment_advice): + return { + "message_type": "warning", + "body": _( + "Product %(product_name)s is already loaded in the shipment " + "%(shipment_name)s." + ) + % { + "product_name": product.name, + "shipment_name": shipment_advice.name, + }, + } + + def carrier_not_allowed_by_shipment(self, picking): + return { + "message_type": "error", + "body": _( + "Delivery method {} not permitted for this shipment advice." + ).format(picking.carrier_id.name), + } + + def no_delivery_content_to_load(self, picking): + return { + "message_type": "error", + "body": _("No more content to load from delivery {}.").format(picking.name), + } + + def scan_operation_first(self): + return { + "message_type": "error", + "body": _("Please first scan the operation."), + } + + def product_owned_by_packages(self, packages): + return { + "message_type": "error", + "body": _("Please scan package(s) {} where this product is.").format( + ", ".join(packages.mapped("name")) + ), + } + + def product_owned_by_lots(self, lots): + return { + "message_type": "error", + "body": _("Please scan lot(s) {} where this product is.").format( + ", ".join(lots.mapped("name")) + ), + } + + def lot_owned_by_packages(self, packages): + return { + "message_type": "error", + "body": _("Please scan package(s) {} where this lot is.").format( + ", ".join(packages.mapped("name")) + ), + } + + def shipment_planned_content_fully_loaded(self): + return { + "message_type": "info", + "body": _("Planned content has been fully loaded."), + } + + def shipment_validated(self, shipment_advice): + return { + "message_type": "info", + "body": _("Shipment {} is validated.").format(shipment_advice.name), + } diff --git a/shopfloor_delivery_shipment/actions/schema.py b/shopfloor_delivery_shipment/actions/schema.py new file mode 100644 index 0000000000..3db6c440b6 --- /dev/null +++ b/shopfloor_delivery_shipment/actions/schema.py @@ -0,0 +1,117 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo.addons.component.core import Component + + +class ShopfloorSchemaAction(Component): + + _inherit = "shopfloor.schema.action" + + def shipment_advice(self): + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "dock": self._schema_dict_of(self.dock()), + "state": {"type": "string", "nullable": False, "required": True}, + "is_planned": {"type": "boolean", "nullable": False, "required": True}, + } + + def dock(self): + return self._simple_record() + + def picking_loaded(self): + schema = self.picking() + schema.update( + { + "loaded_progress_f": { + "type": "float", + "nullable": False, + "required": True, + }, + "loaded_packages_progress_f": { + "type": "float", + "nullable": False, + "required": True, + }, + "loaded_move_lines_progress_f": { + "type": "float", + "nullable": False, + "required": True, + }, + "loaded_progress": { + "type": "string", + "nullable": False, + "required": True, + }, + "loaded_packages_progress": { + "type": "string", + "nullable": False, + "required": True, + }, + "loaded_move_lines_progress": { + "type": "string", + "nullable": False, + "required": True, + }, + "is_fully_loaded": { + "type": "boolean", + "nullable": False, + "required": True, + }, + "is_partially_loaded": { + "type": "boolean", + "nullable": False, + "required": True, + }, + } + ) + return schema + + def shipment_lading_summary(self): + return { + "loaded_pickings_count": { + "type": "integer", + "nullable": False, + "required": True, + }, + "loaded_packages_count": { + "type": "integer", + "nullable": False, + "required": True, + }, + "total_packages_count": { + "type": "integer", + "nullable": False, + "required": True, + }, + "loaded_bulk_lines_count": { + "type": "integer", + "nullable": False, + "required": True, + }, + "total_bulk_lines_count": { + "type": "integer", + "nullable": False, + "required": True, + }, + "loaded_weight": {"type": "integer", "nullable": False, "required": True}, + } + + def shipment_on_dock_summary(self): + return { + "total_pickings_count": { + "type": "integer", + "nullable": False, + "required": True, + }, + "total_packages_count": { + "type": "integer", + "nullable": False, + "required": True, + }, + "total_bulk_lines_count": { + "type": "integer", + "nullable": False, + "required": True, + }, + } diff --git a/shopfloor_delivery_shipment/actions/search.py b/shopfloor_delivery_shipment/actions/search.py new file mode 100644 index 0000000000..163c75b74e --- /dev/null +++ b/shopfloor_delivery_shipment/actions/search.py @@ -0,0 +1,15 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo.addons.component.core import Component + + +class SearchAction(Component): + _inherit = "shopfloor.search.action" + + def dock_from_scan(self, barcode): + model = self.env["stock.dock"] + if not barcode: + return model.browse() + return model.search( + ["|", ("barcode", "=", barcode), ("name", "=", barcode)], limit=1 + ) diff --git a/shopfloor_delivery_shipment/data/shopfloor_scenario_data.xml b/shopfloor_delivery_shipment/data/shopfloor_scenario_data.xml new file mode 100644 index 0000000000..f084b3f0a1 --- /dev/null +++ b/shopfloor_delivery_shipment/data/shopfloor_scenario_data.xml @@ -0,0 +1,11 @@ + + + Delivery with shipment advice + delivery_shipment + +{ + "allow_create_shipment_advice": true +} + + + diff --git a/shopfloor_delivery_shipment/demo/shopfloor_app_demo.xml b/shopfloor_delivery_shipment/demo/shopfloor_app_demo.xml new file mode 100644 index 0000000000..d5651d0753 --- /dev/null +++ b/shopfloor_delivery_shipment/demo/shopfloor_app_demo.xml @@ -0,0 +1,12 @@ + + + Shopfloor WMS (demo) + WMS (demo) + wms_demo + wms + + + diff --git a/shopfloor_delivery_shipment/demo/shopfloor_menu_demo.xml b/shopfloor_delivery_shipment/demo/shopfloor_menu_demo.xml new file mode 100644 index 0000000000..ae78d52545 --- /dev/null +++ b/shopfloor_delivery_shipment/demo/shopfloor_menu_demo.xml @@ -0,0 +1,15 @@ + + + Delivery with shipment + 55 + + + + + diff --git a/shopfloor_delivery_shipment/demo/shopfloor_profile_demo.xml b/shopfloor_delivery_shipment/demo/shopfloor_profile_demo.xml new file mode 100644 index 0000000000..4e55a62826 --- /dev/null +++ b/shopfloor_delivery_shipment/demo/shopfloor_profile_demo.xml @@ -0,0 +1,5 @@ + + + WH delivery ship + + diff --git a/shopfloor_delivery_shipment/docs/delivery_shipment_diag_seq.plantuml b/shopfloor_delivery_shipment/docs/delivery_shipment_diag_seq.plantuml new file mode 100644 index 0000000000..cad7fe529d --- /dev/null +++ b/shopfloor_delivery_shipment/docs/delivery_shipment_diag_seq.plantuml @@ -0,0 +1,49 @@ +# 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 with Shipment Advice scenario + +== /scan_dock (with an available shipment) == +scan_dock -> scan_document: **/scan_dock(barcode)** + +== /scan_dock (without available shipment) == +scan_dock -> scan_dock: **/scan_dock(barcode)**\n(asking for confirmation to create a new shipment) +scan_dock -> scan_document: **/scan_dock(barcode, confirmation=barcode)** + +== /scan_document == +scan_document -> scan_document: **/scan_document**(shipment_advice_id, barcode, picking_id=None, location_id=None) +scan_document -> loading_list: **/scan_document**(shipment_advice_id, barcode, picking_id=None, location_id=None) + +== /unload_move_line == +scan_document -> scan_document: **/unload_move_line**(shipment_advice_id, move_line_id, location_id=None) + +== /unload_package_level == +scan_document -> scan_document: **/unload_package_level**(shipment_advice_id, package_level_id, location_id=None) + +== /loading_list == +scan_document -> loading_list: **/loading_list**(shipment_advice_id) + +== /validate== +loading_list -> validate: **/validate**(shipment_advice_id) +validate-> scan_dock: **/validate**(shipment_advice_id, confirmation=True) + +@enduml diff --git a/shopfloor_delivery_shipment/docs/delivery_shipment_diag_seq.png b/shopfloor_delivery_shipment/docs/delivery_shipment_diag_seq.png new file mode 100644 index 0000000000..1f2d88f82a Binary files /dev/null and b/shopfloor_delivery_shipment/docs/delivery_shipment_diag_seq.png differ diff --git a/shopfloor_delivery_shipment/docs/oca_logo.png b/shopfloor_delivery_shipment/docs/oca_logo.png new file mode 100644 index 0000000000..84f216c294 Binary files /dev/null and b/shopfloor_delivery_shipment/docs/oca_logo.png differ diff --git a/shopfloor_delivery_shipment/hooks.py b/shopfloor_delivery_shipment/hooks.py new file mode 100644 index 0000000000..da9cf0a859 --- /dev/null +++ b/shopfloor_delivery_shipment/hooks.py @@ -0,0 +1,24 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import SUPERUSER_ID, api + +from odoo.addons.shopfloor_base.utils import purge_endpoints, register_new_services + +from .services.delivery_shipment import DeliveryShipment as Service + +_logger = logging.getLogger(__file__) + + +def post_init_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + _logger.info("Register routes for %s", Service._usage) + register_new_services(env, Service) + + +def uninstall_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + _logger.info("Refreshing routes for existing apps") + purge_endpoints(env, Service._usage) diff --git a/shopfloor_delivery_shipment/i18n/es_AR.po b/shopfloor_delivery_shipment/i18n/es_AR.po new file mode 100644 index 0000000000..911047adcc --- /dev/null +++ b/shopfloor_delivery_shipment/i18n/es_AR.po @@ -0,0 +1,197 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_delivery_shipment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2022-11-09 22:45+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_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__allow_shipment_advice_create +msgid "Allow Shipment Advice Creation" +msgstr "Permitir Creación de Avisos de Envío" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Delivery method {} not permitted for this shipment advice." +msgstr "Método de entrega {} no permitido para este aviso de envío." + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.menu,name:shopfloor_delivery_shipment.shopfloor_menu_delivery_shipment +msgid "Delivery with shipment" +msgstr "Entrega con envío" + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.scenario,name:shopfloor_delivery_shipment.scenario_delivery_shipment +msgid "Delivery with shipment advice" +msgstr "Entrega con aviso de envío" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__display_name +msgid "Display Name" +msgstr "Mostrar Nombre" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__id +msgid "ID" +msgstr "ID" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu____last_update +msgid "Last Modified on" +msgstr "Última Modificación el" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} can not been loaded in the shipment {}." +msgstr "Lote {} no puede ser cargado en el envío {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} has not been planned in the shipment {}." +msgstr "Lote {} no fue planeado en el envío {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} is already loaded in the shipment {}." +msgstr "Lote {} ya está cargado en el envío {}." + +#. module: shopfloor_delivery_shipment +#: model:ir.model,name:shopfloor_delivery_shipment.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "Menú mostrado en la aplicación de escaner" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "No more content to load from delivery {}." +msgstr "No más contenido para cargar desde la entrega {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "No shipment advice in progress found for this loading dock." +msgstr "" +"No se ha encontrado ningún aviso de envío en curso para esta dársena de " +"carga." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "" +"No shipment advice in progress found for this loading dock. Scan again {} to " +"create a new one." +msgstr "" +"No se ha encontrado ningún aviso de envío en curso para esta dársena de " +"carga. Escanée {} de nuevo para crear uno nuevo." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} can not been loaded in the shipment {}." +msgstr "El Paquete {} no puede ser cargado en el envío {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} has not been planned in the shipment {}." +msgstr "El Paquete {} no fue planeado en el envío {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} is already loaded in the shipment {}." +msgstr "El Paquete {} ya ha sido cargado en el envío {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Planned content has been fully loaded." +msgstr "El Contenido Planeado ha sido cargado por completo." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please first scan the operation." +msgstr "Escanée la operación primero." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan lot(s) {} where this product is." +msgstr "Scanée lote(s) {} donde esté el producto." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan package(s) {} where this lot is." +msgstr "Escanée paquete(s) {} donde está el lote." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan package(s) {} where this product is." +msgstr "Escanée paquete(s) {} donde esté el producto." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} can not been loaded in the shipment {}." +msgstr "El producto {} no puede ser cargado en el envío {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} has not been planned in the shipment {}." +msgstr "El producto {} no ha sido planeado en el envío {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} is already loaded in the shipment {}." +msgstr "El producto {} ya está cargado en el envío {}." + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__shipment_advice_create_is_possible +msgid "Shipment Advice Create Is Possible" +msgstr "Es posible crear un Aviso de Envío" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Shipment {} is validated." +msgstr "El Envío {} está validado." + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,help:shopfloor_delivery_shipment.field_shopfloor_menu__allow_shipment_advice_create +msgid "" +"Some scenario may create shipment advice(s) automatically when a product or " +"package is scanned and no shipment advice already exists. " +msgstr "" +"Algunos escenarios pueden crear avisos de envío automáticamente cuando se " +"escanea un producto o paquete y ya no existe ningún aviso de envío. " + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Transfer {} has not been planned in the shipment {}." +msgstr "La Transferencia {} no ha sido planeada en el envío {}." + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.profile,name:shopfloor_delivery_shipment.profile_demo_1 +msgid "WH delivery ship" +msgstr "Transporte de Envío WH" diff --git a/shopfloor_delivery_shipment/i18n/it.po b/shopfloor_delivery_shipment/i18n/it.po new file mode 100644 index 0000000000..1891ab39d7 --- /dev/null +++ b/shopfloor_delivery_shipment/i18n/it.po @@ -0,0 +1,198 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_delivery_shipment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-10-18 15:36+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\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.17\n" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__allow_shipment_advice_create +msgid "Allow Shipment Advice Creation" +msgstr "Consente creazione avviso spedizione" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Delivery method {} not permitted for this shipment advice." +msgstr "" +"Il metodo di consegna {} non è consentito per questo avviso di spedizione." + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.menu,name:shopfloor_delivery_shipment.shopfloor_menu_delivery_shipment +msgid "Delivery with shipment" +msgstr "Consegna con spedizione" + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.scenario,name:shopfloor_delivery_shipment.scenario_delivery_shipment +msgid "Delivery with shipment advice" +msgstr "Consegna con avviso di spedizione" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__id +msgid "ID" +msgstr "ID" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} can not been loaded in the shipment {}." +msgstr "Il lotto {} non può essere caricato nella spedizione {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} has not been planned in the shipment {}." +msgstr "Il lotto {} non è pianificato in questa spedizione {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} is already loaded in the shipment {}." +msgstr "Il loto {} è già caricato nella spedizione {}." + +#. module: shopfloor_delivery_shipment +#: model:ir.model,name:shopfloor_delivery_shipment.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "Menu visualizzato nell'applicazione di scansione" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "No more content to load from delivery {}." +msgstr "Non c'è altro da caricare dalla consegna {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "No shipment advice in progress found for this loading dock." +msgstr "" +"Non c'è un avviso di spedizione in corso per questa banchina di carico." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "" +"No shipment advice in progress found for this loading dock. Scan again {} to " +"create a new one." +msgstr "" +"Non c'è un avviso di spedizione in corso per questa banchina di carico. " +"Scansionare nuovamente {} per crearne uno." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} can not been loaded in the shipment {}." +msgstr "Il collo {} non può essere caricato nella spedizione {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} has not been planned in the shipment {}." +msgstr "Il collo {} non è stato pianificato per la psedizione {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} is already loaded in the shipment {}." +msgstr "Il collo {} è già stato caricato nella spedizione {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Planned content has been fully loaded." +msgstr "Il contenuto pianificato è stato completamente caricato." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please first scan the operation." +msgstr "Scansionare prima l'operazione." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan lot(s) {} where this product is." +msgstr "Scansionare i lotti {} dove è presente il prodotto." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan package(s) {} where this lot is." +msgstr "Scansionare i colli {} dove è presente il lotto." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan package(s) {} where this product is." +msgstr "Scansionare i colli {} dove è presente il prodotto." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} can not been loaded in the shipment {}." +msgstr "Il prodotto {} non può essere caricato nlla spedizione {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} has not been planned in the shipment {}." +msgstr "Il prodotto {} non è stato pianificato nlla spedizione {}." + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} is already loaded in the shipment {}." +msgstr "Il prodotto {} è già caricato nella psedizione {}." + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__shipment_advice_create_is_possible +msgid "Shipment Advice Create Is Possible" +msgstr "È possibile creare l'avviso di spedizione" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Shipment {} is validated." +msgstr "La spedizione {} è validata." + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,help:shopfloor_delivery_shipment.field_shopfloor_menu__allow_shipment_advice_create +msgid "" +"Some scenario may create shipment advice(s) automatically when a product or " +"package is scanned and no shipment advice already exists. " +msgstr "" +"Alcuni scenari possono creare avvisi di spedizione automaticamente qando un " +"prodotto o un collo vengono scansionati e non esiste già un avviso di " +"spedizione. " + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Transfer {} has not been planned in the shipment {}." +msgstr "Il trasferimento {} non è stato pianificato nlla spedizione {}." + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.profile,name:shopfloor_delivery_shipment.profile_demo_1 +msgid "WH delivery ship" +msgstr "Spedizione consegna magazzino" diff --git a/shopfloor_delivery_shipment/i18n/pt_BR.po b/shopfloor_delivery_shipment/i18n/pt_BR.po new file mode 100644 index 0000000000..668f3142a6 --- /dev/null +++ b/shopfloor_delivery_shipment/i18n/pt_BR.po @@ -0,0 +1,189 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_delivery_shipment +# +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_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__allow_shipment_advice_create +msgid "Allow Shipment Advice Creation" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Delivery method {} not permitted for this shipment advice." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.menu,name:shopfloor_delivery_shipment.shopfloor_menu_delivery_shipment +msgid "Delivery with shipment" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.scenario,name:shopfloor_delivery_shipment.scenario_delivery_shipment +msgid "Delivery with shipment advice" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__id +msgid "ID" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} can not been loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} has not been planned in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} is already loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model,name:shopfloor_delivery_shipment.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "No more content to load from delivery {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "No shipment advice in progress found for this loading dock." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "" +"No shipment advice in progress found for this loading dock. Scan again {} to " +"create a new one." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} can not been loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} has not been planned in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} is already loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Planned content has been fully loaded." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please first scan the operation." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan lot(s) {} where this product is." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan package(s) {} where this lot is." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan package(s) {} where this product is." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} can not been loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} has not been planned in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} is already loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__shipment_advice_create_is_possible +msgid "Shipment Advice Create Is Possible" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Shipment {} is validated." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,help:shopfloor_delivery_shipment.field_shopfloor_menu__allow_shipment_advice_create +msgid "" +"Some scenario may create shipment advice(s) automatically when a product or " +"package is scanned and no shipment advice already exists. " +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Transfer {} has not been planned in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.profile,name:shopfloor_delivery_shipment.profile_demo_1 +msgid "WH delivery ship" +msgstr "" diff --git a/shopfloor_delivery_shipment/i18n/shopfloor_delivery_shipment.pot b/shopfloor_delivery_shipment/i18n/shopfloor_delivery_shipment.pot new file mode 100644 index 0000000000..7c1b58c21c --- /dev/null +++ b/shopfloor_delivery_shipment/i18n/shopfloor_delivery_shipment.pot @@ -0,0 +1,188 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_delivery_shipment +# +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_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__allow_shipment_advice_create +msgid "Allow Shipment Advice Creation" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Delivery method {} not permitted for this shipment advice." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.menu,name:shopfloor_delivery_shipment.shopfloor_menu_delivery_shipment +msgid "Delivery with shipment" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.scenario,name:shopfloor_delivery_shipment.scenario_delivery_shipment +msgid "Delivery with shipment advice" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__id +msgid "ID" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} can not been loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} has not been planned in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Lot {} is already loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model,name:shopfloor_delivery_shipment.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "No more content to load from delivery {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "No shipment advice in progress found for this loading dock." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "" +"No shipment advice in progress found for this loading dock. Scan again {} to" +" create a new one." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} can not been loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} has not been planned in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Package {} is already loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Planned content has been fully loaded." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please first scan the operation." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan lot(s) {} where this product is." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan package(s) {} where this lot is." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Please scan package(s) {} where this product is." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} can not been loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} has not been planned in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Product {} is already loaded in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,field_description:shopfloor_delivery_shipment.field_shopfloor_menu__shipment_advice_create_is_possible +msgid "Shipment Advice Create Is Possible" +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Shipment {} is validated." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:ir.model.fields,help:shopfloor_delivery_shipment.field_shopfloor_menu__allow_shipment_advice_create +msgid "" +"Some scenario may create shipment advice(s) automatically when a product or " +"package is scanned and no shipment advice already exists. " +msgstr "" + +#. module: shopfloor_delivery_shipment +#: code:addons/shopfloor_delivery_shipment/actions/message.py:0 +#, python-format +msgid "Transfer {} has not been planned in the shipment {}." +msgstr "" + +#. module: shopfloor_delivery_shipment +#: model:shopfloor.profile,name:shopfloor_delivery_shipment.profile_demo_1 +msgid "WH delivery ship" +msgstr "" diff --git a/shopfloor_delivery_shipment/models/__init__.py b/shopfloor_delivery_shipment/models/__init__.py new file mode 100644 index 0000000000..8bd3d5195c --- /dev/null +++ b/shopfloor_delivery_shipment/models/__init__.py @@ -0,0 +1 @@ +from . import shopfloor_menu diff --git a/shopfloor_delivery_shipment/models/shopfloor_menu.py b/shopfloor_delivery_shipment/models/shopfloor_menu.py new file mode 100644 index 0000000000..4c30d4c3ba --- /dev/null +++ b/shopfloor_delivery_shipment/models/shopfloor_menu.py @@ -0,0 +1,24 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo import api, fields, models + + +class ShopfloorMenu(models.Model): + _inherit = "shopfloor.menu" + + shipment_advice_create_is_possible = fields.Boolean( + compute="_compute_shipment_advice_create_is_possible" + ) + allow_shipment_advice_create = fields.Boolean( + string="Allow Shipment Advice Creation", + default=False, + help="Some scenario may create shipment advice(s) automatically when a " + "product or package is scanned and no shipment advice already exists. ", + ) + + @api.depends("scenario_id") + def _compute_shipment_advice_create_is_possible(self): + for menu in self: + menu.shipment_advice_create_is_possible = menu.scenario_id.has_option( + "allow_create_shipment_advice" + ) diff --git a/shopfloor_delivery_shipment/readme/CONTRIBUTORS.rst b/shopfloor_delivery_shipment/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..f5376bbb14 --- /dev/null +++ b/shopfloor_delivery_shipment/readme/CONTRIBUTORS.rst @@ -0,0 +1,7 @@ +* Sébastien Alix + +Design +~~~~~~ + +* Joël Grand-Guillaume +* Jacques-Etienne Baudoux diff --git a/shopfloor_delivery_shipment/readme/CREDITS.rst b/shopfloor_delivery_shipment/readme/CREDITS.rst new file mode 100644 index 0000000000..4641e10661 --- /dev/null +++ b/shopfloor_delivery_shipment/readme/CREDITS.rst @@ -0,0 +1,4 @@ +**Financial support** + +* Cosanum +* Camptocamp R&D diff --git a/shopfloor_delivery_shipment/readme/DESCRIPTION.rst b/shopfloor_delivery_shipment/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..a6ad8af01a --- /dev/null +++ b/shopfloor_delivery_shipment/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Shopfloor scenario to manage the delivery process based on shipment advices. diff --git a/shopfloor_delivery_shipment/services/__init__.py b/shopfloor_delivery_shipment/services/__init__.py new file mode 100644 index 0000000000..b20be04c69 --- /dev/null +++ b/shopfloor_delivery_shipment/services/__init__.py @@ -0,0 +1 @@ +from . import delivery_shipment diff --git a/shopfloor_delivery_shipment/services/delivery_shipment.py b/shopfloor_delivery_shipment/services/delivery_shipment.py new file mode 100644 index 0000000000..6e09659bbe --- /dev/null +++ b/shopfloor_delivery_shipment/services/delivery_shipment.py @@ -0,0 +1,1055 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import collections + +from odoo import fields + +from odoo.addons.base_rest.components.service import to_bool, to_int +from odoo.addons.component.core import Component + + +class DeliveryShipment(Component): + """Methods for the Delivery with Shipment Advices process + + Deliver the goods by processing the PACK and raw products by delivery order + into a shipment advice. + + Multiple operators could be processing a same delivery order. + + You will find a sequence diagram describing states and endpoints + relationships [here](../docs/delivery_shipment_diag_seq.png). + Keep [the sequence diagram](../docs/delivery_shipment_diag_seq.plantuml) + up-to-date if you change endpoints. + + Three cases: + + * Manager assign shipment advice to loading dock, plan its content and start them + * Manager assign shipment advice to loading dock without content planning and start them + * Operators create shipment advice on the fly (option “Allow shipment advice creation” + in the scenario) + + 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 + * A shipment advice is marked as done + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.delivery.shipment" + _usage = "delivery_shipment" + _description = __doc__ + + def scan_dock(self, barcode, confirmation=False): + """Scan a loading dock. + + Called at the beginning of the workflow to select the shipment advice + (corresponding to the scanned loading dock) to work on. + + If no shipment advice in progress related to the scanned loading dock + is found, a new one is created if the menu as the option + "Allow to create shipment advice" enabled. + + Transitions: + * scan_document: a shipment advice has been found or created (with or + without planned moves) + * scan_dock: no shipment advice found + """ + if not barcode: + # End point called with the back button + return self._response_for_scan_dock() + search = self._actions_for("search") + dock = search.dock_from_scan(barcode) + if dock: + shipment_advice = self._find_shipment_advice_from_dock(dock) + if not shipment_advice: + if not self.work.menu.allow_shipment_advice_create: + return self._response_for_scan_dock( + message=self.msg_store.no_shipment_in_progress() + ) + if not confirmation: + return self._response_for_scan_dock( + message=self.msg_store.scan_dock_again_to_confirm(dock), + confirmation_required=barcode, + ) + if confirmation != barcode: + return self._response_for_scan_dock( + message=self.msg_store.scan_dock_again_to_confirm(dock), + confirmation_required=barcode, + ) + shipment_advice = self._create_shipment_advice_from_dock(dock) + return self._response_for_scan_document(shipment_advice) + return self._response_for_scan_dock(message=self.msg_store.barcode_not_found()) + + def scan_document( + self, shipment_advice_id, barcode, picking_id=None, location_id=None + ): + """Scan an operation or location; a package, a product or a lot. + + If an operation or a location is scanned, reload the screen with the related planned + content or full content of this operation/location for this shipment advice. + + If a package, a product or a lot is scanned, it will be loaded in the + current shipment advice and the screen will be reloaded with the related + current location or operation listing its planned or full content. + + If all the planned content (if any) has been loaded, redirect the user + to the next state 'loading_list'. + + Transitions: + * scan_document: once a good is loaded, or a operation has been + scanned, or in case of error + * loading_list: all planned content (if any) have been processed + * scan_dock: error (shipment not found...) + """ + shipment_advice = ( + self.env["shipment.advice"].browse(shipment_advice_id).exists() + ) + if not shipment_advice: + return self._response_for_scan_dock( + message=self.msg_store.record_not_found() + ) + picking = None + if picking_id: + picking = self.env["stock.picking"].browse(picking_id).exists() + if not picking: + return self._response_for_scan_document( + shipment_advice, message=self.msg_store.stock_picking_not_found() + ) + message = self._check_picking_status(picking, shipment_advice) + if message: + return self._response_for_scan_document( + shipment_advice, message=message + ) + location = None + if location_id: + # Filtering location the user is working from. + location = self.env["stock.location"].browse(location_id).exists() + if not barcode: + # End point called with the back button + return self._response_for_scan_document(shipment_advice, picking, location) + search = self._actions_for("search") + # Look for a location + scanned_location = search.location_from_scan(barcode) + if scanned_location: + return self._scan_location(shipment_advice, scanned_location) + # Look for an operation + scanned_picking = search.picking_from_scan(barcode) + if scanned_picking: + return self._scan_picking(shipment_advice, scanned_picking) + # Look for a package + scanned_package = search.package_from_scan(barcode) + if scanned_package: + return self._scan_package( + shipment_advice, scanned_package, picking, location + ) + # Look for a lot (restricted to the relevant products as a lot number + # can be shared by different products) + move_lines = self.env["stock.move.line"].search( + self._find_move_lines_domain(shipment_advice) + ) + scanned_lot = search.lot_from_scan(barcode, products=move_lines.product_id) + if scanned_lot: + return self._scan_lot(shipment_advice, scanned_lot, picking, location) + scanned_product = search.product_from_scan(barcode) + if not scanned_product: + packaging = search.packaging_from_scan(barcode) + scanned_product = packaging.product_id + if scanned_product: + return self._scan_product( + shipment_advice, scanned_product, picking, location + ) + return self._response_for_scan_document( + shipment_advice, + picking, + location=location, + message=self.msg_store.barcode_not_found(), + ) + + def _scan_location(self, shipment_advice, location): + """Return the planned or available content of the scanned location.""" + if not self.is_src_location_valid(location): + message = self.msg_store.location_not_allowed() + return self._response_for_scan_document(shipment_advice, message=message) + return self._response_for_scan_document(shipment_advice, location=location) + + def _scan_picking(self, shipment_advice, picking): + """Return the planned or available content of the scanned delivery for + the current shipment advice. + + If the shipment advice had planned content and that the scanned delivery + is not part of it, returns an error message. + """ + message = self._check_picking_status(picking, shipment_advice) + if message: + return self._response_for_scan_document(shipment_advice, message=message) + else: + # Check that the delivery has available and not loaded content to load + move_lines_to_process = self._find_move_lines_to_process_from_picking( + shipment_advice, picking + ) + if not move_lines_to_process: + return self._response_for_scan_document( + shipment_advice, + message=self.msg_store.no_delivery_content_to_load(picking), + ) + return self._response_for_scan_document(shipment_advice, picking) + + def _scan_package(self, shipment_advice, package, picking, location): + """Load the package in the shipment advice. + + Find the package level or move line (of the planned shipment advice in + priority if any) corresponding to the scanned package and load it. + If no content is found an error will be returned. + """ + move_lines = self._find_move_lines_from_package( + shipment_advice, package, picking, location + ) + if move_lines: + # Check transfer status + message = self._check_picking_status(move_lines.picking_id, shipment_advice) + if message: + return self._response_for_scan_document( + shipment_advice, + location=location, + message=message, + ) + # Check that the product isn't already loaded + package_level = move_lines.package_level_id + if package_level._is_loaded_in_shipment(): + return self._response_for_scan_document( + shipment_advice, + move_lines.picking_id, + location=location, + message=self.msg_store.package_already_loaded_in_shipment( + package, shipment_advice + ), + ) + # Load the package + package_level._load_in_shipment(shipment_advice) + return self._response_for_scan_document_or_loading_list( + shipment_advice, + move_lines.picking_id, + location=location, + ) + message = None + if location: + message = self.msg_store.package_not_found_in_location(package, location) + elif picking: + message = self.msg_store.package_not_found_in_picking(package, picking) + elif shipment_advice.planned_move_ids: + message = self.msg_store.package_not_planned_in_shipment( + package, shipment_advice + ) + else: + message = self.msg_store.unable_to_load_package_in_shipment( + package, shipment_advice + ) + return self._response_for_scan_document( + shipment_advice, picking, location, message + ) + + def _scan_lot(self, shipment_advice, lot, picking, location): + """Load the lot in the shipment advice. + + Find the first move line (of the planned shipment advice in + priority if any) corresponding to the scanned lot and load it. + If no move line is found an error will be returned. + """ + move_lines = self._find_move_lines_from_lot( + shipment_advice, lot, picking, location + ) + if move_lines: + # Check transfer status + message = self._check_picking_status(move_lines.picking_id, shipment_advice) + if message: + return self._response_for_scan_document( + shipment_advice, location=location, message=message + ) + # Check that the lot doesn't belong to a package + package_levels_not_loaded = move_lines.package_level_id.filtered( + lambda pl: not pl.is_done + ) + if package_levels_not_loaded: + return self._response_for_scan_document( + shipment_advice, + move_lines.picking_id, + message=self.msg_store.lot_owned_by_packages( + package_levels_not_loaded.package_id + ), + location=location, + ) + # Check that the lot isn't already loaded + if move_lines._is_loaded_in_shipment(): + return self._response_for_scan_document( + shipment_advice, + move_lines.picking_id, + message=self.msg_store.lot_already_loaded_in_shipment( + lot, shipment_advice + ), + location=location, + ) + # Load the lot + move_lines._load_in_shipment(shipment_advice) + return self._response_for_scan_document_or_loading_list( + shipment_advice, move_lines.picking_id, location=location + ) + message = None + if location: + message = self.msg_store.lot_not_found_in_location(lot, location) + elif picking: + message = self.msg_store.lot_not_found_in_picking(lot, picking) + elif shipment_advice.planned_move_ids: + message = self.msg_store.lot_not_planned_in_shipment(lot, shipment_advice) + else: + message = self.msg_store.unable_to_load_lot_in_shipment( + lot, shipment_advice + ) + return self._response_for_scan_document( + shipment_advice, picking, location, message + ) + + def _scan_product(self, shipment_advice, product, picking, location): + """Load the product in the shipment advice. + + Find the first move line (of the planned shipment advice in + priority if any) corresponding to the scanned product and load it. + If no move line is found an error will be returned. + """ + if not picking: + return self._response_for_scan_document( + shipment_advice, + message=self.msg_store.scan_operation_first(), + ) + move_lines = self._find_move_lines_from_product( + shipment_advice, product, picking, location + ) + if move_lines: + # Check transfer status + message = self._check_picking_status(move_lines.picking_id, shipment_advice) + if message: + return self._response_for_scan_document( + shipment_advice, message=message + ) + # Check if product lines are linked to some packages + # In this case we want to process the package as a whole + package_levels_not_loaded = move_lines.package_level_id.filtered( + lambda pl: not pl.is_done + ) + if package_levels_not_loaded: + return self._response_for_scan_document( + shipment_advice, + picking, + message=self.msg_store.product_owned_by_packages( + package_levels_not_loaded.package_id + ), + ) + # Check if product lines are linked to a lot + # If there are several lots corresponding to this product, we want + # to scan a lot instead of a product + if product.tracking != "none": + lots_not_loaded = move_lines.filtered( + lambda ml: ( + not ml.package_level_id + and ml.qty_done != ml.product_uom_qty + and ml.lot_id + ) + ) + if len(lots_not_loaded) > 1: + return self._response_for_scan_document( + shipment_advice, + picking, + message=self.msg_store.product_owned_by_lots( + lots_not_loaded.lot_id + ), + ) + # Check that the product isn't already loaded + if move_lines._is_loaded_in_shipment(): + return self._response_for_scan_document( + shipment_advice, + picking, + message=self.msg_store.product_already_loaded_in_shipment( + product, shipment_advice + ), + ) + # Load the lines + move_lines._load_in_shipment(shipment_advice) + return self._response_for_scan_document_or_loading_list( + shipment_advice, + move_lines.picking_id, + ) + message = None + if location: + message = self.msg_store.product_not_found_in_location_or_transfer( + product, location, picking + ) + elif shipment_advice.planned_move_ids: + message = self.msg_store.product_not_planned_in_shipment( + product, shipment_advice + ) + else: + message = self.msg_store.unable_to_load_product_in_shipment( + product, shipment_advice + ) + return self._response_for_scan_document( + shipment_advice, picking, location, message + ) + + def unload_move_line(self, shipment_advice_id, move_line_id, location_id=None): + """Unload a move line from a shipment advice. + + Transitions: + * scan_document: reload the screen once the move line is unloaded + * scan_dock: error (record ID not found...) + """ + shipment_advice = ( + self.env["shipment.advice"].browse(shipment_advice_id).exists() + ) + move_line = self.env["stock.move.line"].browse(move_line_id).exists() + if not shipment_advice or not move_line: + return self._response_for_scan_dock( + message=self.msg_store.record_not_found() + ) + location = None + if location_id: + # Filtering location the user is working from. + location = self.env["stock.location"].browse(location_id).exists() + # Unload the move line + move_line._unload_from_shipment() + return self._response_for_scan_document( + shipment_advice, move_line.picking_id, location=location + ) + + def unload_package_level( + self, shipment_advice_id, package_level_id, location_id=None + ): + """Unload a package level from a shipment advice. + + Transitions: + * scan_document: reload the screen once the package level is unloaded + * scan_dock: error (record ID not found...) + """ + shipment_advice = ( + self.env["shipment.advice"].browse(shipment_advice_id).exists() + ) + package_level = ( + self.env["stock.package_level"].browse(package_level_id).exists() + ) + if not shipment_advice or not package_level: + return self._response_for_scan_dock( + message=self.msg_store.record_not_found() + ) + location = None + if location_id: + # Filtering location the user is working from. + location = self.env["stock.location"].browse(location_id).exists() + # Unload the package level + package_level._unload_from_shipment() + return self._response_for_scan_document( + shipment_advice, package_level.picking_id, location=location + ) + + def loading_list(self, shipment_advice_id): + """Redirect to the 'loading_list' state listing the loaded (with their + loading progress) and not loaded deliveries for the given shipment. + """ + shipment_advice = ( + self.env["shipment.advice"].browse(shipment_advice_id).exists() + ) + if not shipment_advice: + return self._response_for_scan_dock( + message=self.msg_store.record_not_found() + ) + return self._response_for_loading_list(shipment_advice) + + def validate(self, shipment_advice_id, confirmation=False): + """Validate the shipment advice. + + When called the first time with `confirmation=False`, it returns a summary + of the deliveries (loaded or that can still be loaded) for that shipment. + """ + shipment_advice = ( + self.env["shipment.advice"].browse(shipment_advice_id).exists() + ) + if not shipment_advice: + return self._response_for_scan_dock( + message=self.msg_store.record_not_found() + ) + if not confirmation: + return self._response_for_validate(shipment_advice) + shipment_advice.action_done() + return self._response_for_scan_dock( + message=self.msg_store.shipment_validated(shipment_advice) + ) + + def _response_for_scan_dock(self, message=None, confirmation_required=None): + """Transition to the 'scan_dock' state. + + The client screen invite the user to scan a dock to find or create an + available shipment advice. + + If `confirmation_required` is set, the client will ask to scan again + the dock to create a shipment advice. + """ + data = {"confirmation_required": confirmation_required} + return self._response(next_state="scan_dock", data=data, message=message) + + def _response_for_scan_document( + self, shipment_advice, picking=None, location=None, message=None + ): + data = { + "shipment_advice": self.data.shipment_advice(shipment_advice), + } + # The filter on location takes priority on the picking filter + if location: + data.update( + location=self.data.location(location), + content=self._data_for_content_to_load_from_picking( + shipment_advice, location=location + ), + ) + elif picking: + data.update( + picking=self.data.picking(picking), + content=self._data_for_content_to_load_from_picking( + shipment_advice, picking + ), + ) + else: + data.update( + content=self._data_for_content_to_load_from_pickings(shipment_advice), + ) + return self._response(next_state="scan_document", data=data, message=message) + + def _response_for_loading_list(self, shipment_advice, message=None): + data = { + "shipment_advice": self.data.shipment_advice(shipment_advice), + "lading": self._data_for_lading(shipment_advice), + "on_dock": self._data_for_on_dock(shipment_advice), + } + return self._response(next_state="loading_list", data=data, message=message) + + def _response_for_scan_document_or_loading_list( + self, shipment_advice, picking, message=None, location=None + ): + """Route on 'scan_document' or 'loading_list' states. + + If all planned moves of the shipment are loaded, route to 'loading_list', + state otherwise redirect to 'scan_document'. + """ + planned_moves = shipment_advice.planned_move_ids + loaded_move_lines = shipment_advice.loaded_move_line_ids + if planned_moves and planned_moves.move_line_ids == loaded_move_lines: + return self._response_for_loading_list( + shipment_advice, + message=self.msg_store.shipment_planned_content_fully_loaded(), + ) + return self._response_for_scan_document( + shipment_advice, picking, location=location, message=message + ) + + def _response_for_validate(self, shipment_advice, message=None): + data = { + "shipment_advice": self.data.shipment_advice(shipment_advice), + "lading": self._data_for_lading_summary(shipment_advice), + "on_dock": self._data_for_on_dock_summary(shipment_advice), + } + return self._response(next_state="validate", data=data, message=message) + + def _data_for_content_to_load_from_pickings(self, shipment_advice): + """Return goods to load from transfers partially loaded in a shipment. + + It returns a dict where keys are source locations and values are + dictionaries listing package_levels and move_lines that remain to load + from transfers partially loaded in a shipment. + + E.g: + { + "SRC_LOCATION1": { + "package_levels": [{PKG_LEVEL_DATA}, ...], + "move_lines": [{MOVE_LINE_DATA}, ...], + }, + "SRC_LOCATION2": { + ... + }, + } + """ + domain = self._find_move_lines_domain(shipment_advice) + # Restrict to lines not loaded + domain.insert(0, ("shipment_advice_id", "=", False)) + # Find lines to load from partially loaded transfers if the shipment + # is not planned. + if not shipment_advice.planned_move_ids: + all_lines_to_load = self.env["stock.move.line"].search(domain) + all_pickings = all_lines_to_load.picking_id + loaded_lines = self.env["stock.move.line"].search( + [ + ("picking_id", "in", all_pickings.ids), + ("id", "not in", all_lines_to_load.ids), + ("shipment_advice_id", "!=", False), + ] + ) + pickings_partially_loaded = loaded_lines.picking_id + domain += [("picking_id", "in", pickings_partially_loaded.ids)] + move_lines = self.env["stock.move.line"].search(domain) + return self._prepare_data_for_content(move_lines) + + def _data_for_content_to_load_from_picking( + self, shipment_advice, picking=None, location=None + ): + """Return a dictionary where keys are source locations + and values are dictionaries listing package_levels and move_lines + loaded or to load. + + E.g: + { + "SRC_LOCATION1": { + "package_levels": [{PKG_LEVEL_DATA}, ...], + "move_lines": [{MOVE_LINE_DATA}, ...], + }, + "SRC_LOCATION2": { + ... + }, + } + """ + # Grab move lines to sort, restricted to the current delivery + if picking: + move_lines = self._find_move_lines_to_process_from_picking( + shipment_advice, picking + ) + elif location: + move_lines = self._find_move_lines_from_location(shipment_advice, location) + return self._prepare_data_for_content(move_lines) + + def _prepare_data_for_content(self, move_lines): + data = collections.OrderedDict() + package_level_ids = [] + # Sort and group move lines by source location and prepare the data + for move_line in move_lines.sorted(lambda ml: ml.location_id.name): + location_data = data.setdefault(move_line.location_id.name, {}) + if move_line.package_level_id: + pl_data = location_data.setdefault("package_levels", []) + if move_line.package_level_id.id in package_level_ids: + continue + pl_data.append(self.data.package_level(move_line.package_level_id)) + package_level_ids.append(move_line.package_level_id.id) + else: + location_data.setdefault("move_lines", []).append( + self.data.move_line(move_line) + ) + return data + + def _data_for_lading(self, shipment_advice): + """Return a list of deliveries loaded in the shipment advice. + + The deliveries could be partially or fully loaded. For each of them a % + of content loaded is computed (either based on bulk content or packages). + """ + pickings = shipment_advice.loaded_picking_ids.filtered( + lambda p: p.picking_type_id & self.picking_types + ).sorted("loaded_progress_f") + return self.data.pickings_loaded(pickings) + + def _data_for_lading_summary(self, shipment_advice): + """Return the number of deliveries/packages/bulk lines loaded.""" + pickings = shipment_advice.loaded_picking_ids.filtered( + lambda p: p.picking_type_id & self.picking_types + ).sorted("loaded_progress_f") + return { + "loaded_pickings_count": len(pickings), + "loaded_packages_count": sum(pickings.mapped("loaded_packages_count")), + "total_packages_count": sum(pickings.mapped("total_packages_count")), + "loaded_bulk_lines_count": sum(pickings.mapped("loaded_move_lines_count")), + "total_bulk_lines_count": sum(pickings.mapped("total_move_lines_count")), + "loaded_weight": sum(pickings.mapped("loaded_weight")), + } + + def _data_for_on_dock(self, shipment_advice): + """Return a list of deliveries not loaded in the shipment advice. + + The deliveries are not loaded at all in the shipment. + """ + return self.data.pickings( + self._find_pickings_not_loaded_from_shipment(shipment_advice) + ) + + def _data_for_on_dock_summary(self, shipment_advice): + """Return the number of deliveries/packages/bulk lines not loaded.""" + pickings = self._find_pickings_not_loaded_from_shipment(shipment_advice) + return { + "total_pickings_count": len(pickings), + "total_packages_count": sum(pickings.mapped("total_packages_count")), + "total_bulk_lines_count": sum(pickings.mapped("total_move_lines_count")), + } + + def _find_shipment_advice_from_dock_domain(self, dock): + return [ + ("dock_id", "=", dock.id), + ("state", "in", ["in_progress"]), + ("warehouse_id", "in", self.picking_types.warehouse_id.ids), + ] + + def _find_shipment_advice_from_dock(self, dock): + return self.env["shipment.advice"].search( + self._find_shipment_advice_from_dock_domain(dock), + limit=1, + order="arrival_date", + ) + + def _prepare_shipment_advice_from_dock_values(self, dock): + return { + "dock_id": dock.id, + "arrival_date": fields.Datetime.to_string(fields.Datetime.now()), + "warehouse_id": dock.warehouse_id.id, + } + + def _create_shipment_advice_from_dock(self, dock): + shipment_advice = self.env["shipment.advice"].create( + self._prepare_shipment_advice_from_dock_values(dock) + ) + shipment_advice.action_confirm() + shipment_advice.action_in_progress() + return shipment_advice + + def _find_move_lines_from_location(self, shipment_advice, location): + """Returns the move line corresponding to `location` for the given shipment.""" + location.ensure_one() + domain = self._find_move_lines_domain(shipment_advice) + domain.append(("location_id", "child_of", location.id)) + return self.env["stock.move.line"].search(domain) + + def _find_move_lines_to_process_from_picking(self, shipment_advice, picking): + """Returns the moves to load or unload for the given shipment and delivery. + + - if the shipment is planned, returns delivery content planned for + this shipment + - if the shipment is not planned, returns delivery content to + load/unload (not planned and not loaded in another shipment) + """ + picking.ensure_one() + # Shipment with planned content + if shipment_advice.planned_move_ids: + # Restrict to delivery planned moves + moves = (shipment_advice.planned_move_ids & picking.move_ids).filtered( + lambda m: m.state in ("assigned", "partially_available") + ) + # Shipment without planned content + else: + # Restrict to delivery moves not planned + moves = picking.move_ids.filtered(lambda m: not m.shipment_advice_id) + return moves.move_line_ids.filtered( + lambda ml: not ml.shipment_advice_id + or shipment_advice & ml.shipment_advice_id + ) + + def _find_move_lines_domain(self, shipment_advice): + """Returns the base domain to look for move lines for a given shipment.""" + return shipment_advice._find_move_lines_domain( + self.picking_types + ) # Defined in `shipment_advice` + + def _find_move_lines_from_package( + self, shipment_advice, package, picking, location + ): + """Returns the move line corresponding to `package` for the given shipment.""" + domain = self._find_move_lines_domain(shipment_advice) + # FIXME should we check also result package here? + domain.append(("package_id", "=", package.id)) + if location: + domain.append( + ("location_id", "child_of", location.id), + ) + if picking: + domain.append( + ("picking_id", "=", picking.id), + ) + return self.env["stock.move.line"].search(domain) + + def _find_move_lines_from_lot(self, shipment_advice, lot, picking, location): + """Returns the move line corresponding to `lot` for the given shipment.""" + domain = self._find_move_lines_domain(shipment_advice) + domain.append(("lot_id", "=", lot.id)) + if location: + domain.append( + ("location_id", "child_of", location.id), + ) + if picking: + domain.append( + ("picking_id", "=", picking.id), + ) + return self.env["stock.move.line"].search(domain) + + def _find_move_lines_from_product( + self, + shipment_advice, + product, + picking, + location, + in_package_not_loaded=False, + in_lot=False, + ): + """Returns the move lines corresponding to `product` and `picking` + for the given shipment. + """ + domain = self._find_move_lines_domain(shipment_advice) + domain.extend( + [ + ("product_id", "=", product.id), + ("picking_id", "=", picking.id), + ] + ) + if location: + domain.append( + ("location_id", "child_of", location.id), + ) + if in_package_not_loaded: + domain.append( + ("package_level_id", "!=", False), + ("package_level_id.is_done", "=", False), + ) + if in_lot: + domain.append(("lot_id", "!=", False)) + return self.env["stock.move.line"].search(domain) + + def _find_move_lines_not_loaded_from_shipment(self, shipment_advice): + """Returns the move lines not loaded at all from the shipment advice.""" + domain = self._find_move_lines_domain(shipment_advice) + domain.append(("qty_done", "=", 0)) + return self.env["stock.move.line"].search(domain) + + def _find_pickings_not_loaded_from_shipment(self, shipment_advice): + """Returns the deliveries that are not loaded for the given shipment.""" + pickings_loaded = shipment_advice.loaded_picking_ids.filtered( + lambda p: p.picking_type_id & self.picking_types + ) + # Shipment with planned content + if shipment_advice.planned_move_ids: + pickings_planned = shipment_advice.planned_picking_ids.filtered( + lambda p: p.picking_type_id & self.picking_types + ) + pickings_not_loaded = pickings_planned - pickings_loaded + # Shipment without planned content + else: + # Deliveries not loaded have all their move lines not loaded at all + # (even partially) + move_lines_not_loaded = self._find_move_lines_not_loaded_from_shipment( + shipment_advice + ) + pickings_not_loaded = ( + move_lines_not_loaded.picking_id - shipment_advice.loaded_picking_ids + ) + return pickings_not_loaded + + def _check_picking_status(self, pickings, shipment_advice): + # Overloaded to add checks against a shipment advice + message = super()._check_picking_status(pickings) + if message: + return message + for picking in pickings: + # Shipment with planned content + if shipment_advice.planned_move_ids: + if picking not in shipment_advice.planned_picking_ids: + return self.msg_store.picking_not_planned_in_shipment( + picking, shipment_advice + ) + # Check carrier's provider compatibility between shipment and picking + carriers = shipment_advice.carrier_ids + shipment_carrier_types = set(carriers.mapped("delivery_type")) + picking_carrier_type = {picking.carrier_id.delivery_type} + if carriers and not shipment_carrier_types & picking_carrier_type: + return self.msg_store.carrier_not_allowed_by_shipment(picking) + + +class ShopfloorDeliveryShipmentValidator(Component): + """Validators for the Delivery with Shipment Advices endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.delivery.shipment.validator" + _usage = "delivery_shipment.validator" + + def scan_dock(self): + return { + "barcode": {"required": True, "type": "string"}, + "confirmation": { + "required": False, + "nullable": True, + "type": "string", + }, + } + + def scan_document(self): + return { + "shipment_advice_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "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 unload_move_line(self): + return { + "shipment_advice_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_id": { + "coerce": to_int, + "required": False, + "nullable": True, + "type": "integer", + }, + } + + def unload_package_level(self): + return { + "shipment_advice_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "package_level_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "location_id": { + "coerce": to_int, + "required": False, + "nullable": True, + "type": "integer", + }, + } + + def loading_list(self): + return { + "shipment_advice_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + } + + def validate(self): + return { + "shipment_advice_id": { + "coerce": to_int, + "required": True, + "type": "integer", + }, + "confirmation": { + "coerce": to_bool, + "required": False, + "nullable": True, + "type": "boolean", + }, + } + + +class ShopfloorDeliveryShipmentValidatorResponse(Component): + """Validators for the Delivery with Shipment Advices endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.delivery.shipment.validator.response" + _usage = "delivery_shipment.validator.response" + + _start_state = "scan_dock" + + 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 { + "scan_dock": self._schema_scan_dock, + "scan_document": self._schema_scan_document, + "loading_list": self._schema_loading_list, + "validate": self._schema_validate, + } + + @property + def _schema_scan_dock(self): + return { + "confirmation_required": { + "type": "string", + "nullable": True, + "required": False, + }, + } + + @property + def _schema_scan_document(self): + shipment_schema = self.schemas.shipment_advice() + picking_schema = self.schemas.picking() + location_schema = self.schemas.location() + return { + "shipment_advice": { + "type": "dict", + "nullable": False, + "schema": shipment_schema, + }, + "picking": {"type": "dict", "nullable": True, "schema": picking_schema}, + "content": { + "type": "dict", + "nullable": True, + # TODO + # "schema": shipment_schema, + }, + "location": {"type": "dict", "nullable": True, "schema": location_schema}, + } + + @property + def _schema_loading_list(self): + shipment_schema = self.schemas.shipment_advice() + picking_loaded_schema = self.schemas.picking_loaded() + picking_schema = self.schemas.picking() + return { + "shipment_advice": self.schemas._schema_dict_of(shipment_schema), + "lading": self.schemas._schema_list_of(picking_loaded_schema), + "on_dock": self.schemas._schema_list_of(picking_schema), + } + + @property + def _schema_validate(self): + shipment_schema = self.schemas.shipment_advice() + shipment_lading_summary_schema = self.schemas.shipment_lading_summary() + shipment_on_dock_summary_schema = self.schemas.shipment_on_dock_summary() + return { + "shipment_advice": { + "type": "dict", + "nullable": False, + "schema": shipment_schema, + }, + "lading": self.schemas._schema_dict_of(shipment_lading_summary_schema), + "on_dock": self.schemas._schema_dict_of(shipment_on_dock_summary_schema), + } + + def scan_dock(self): + return self._response_schema(next_states={"scan_document", "scan_dock"}) + + def scan_document(self): + return self._response_schema( + next_states={"scan_document", "scan_dock", "loading_list"} + ) + + def loading_list(self): + return self._response_schema(next_states={"loading_list", "scan_dock"}) + + def validate(self): + return self._response_schema(next_states={"validate", "scan_dock"}) diff --git a/shopfloor_delivery_shipment/static/description/icon.png b/shopfloor_delivery_shipment/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/shopfloor_delivery_shipment/static/description/icon.png differ diff --git a/shopfloor_delivery_shipment/static/description/index.html b/shopfloor_delivery_shipment/static/description/index.html new file mode 100644 index 0000000000..1b0897c3ba --- /dev/null +++ b/shopfloor_delivery_shipment/static/description/index.html @@ -0,0 +1,448 @@ + + + + + +Shopfloor - Delivery with shipment advice + + + +
+

Shopfloor - Delivery with shipment advice

+ + +

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

+

Shopfloor scenario to manage the delivery process based on shipment advices.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Design

+ +
+
+

Other credits

+

Financial support

+
    +
  • Cosanum
  • +
  • Camptocamp 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:

+

sebalix TDu

+

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_delivery_shipment/tests/__init__.py b/shopfloor_delivery_shipment/tests/__init__.py new file mode 100644 index 0000000000..45ea6e1c19 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/__init__.py @@ -0,0 +1,10 @@ +from . import test_delivery_shipment_base +from . import test_delivery_shipment_scan_dock +from . import test_delivery_shipment_scan_document_picking +from . import test_delivery_shipment_scan_document_package +from . import test_delivery_shipment_scan_document_location +from . import test_delivery_shipment_scan_document_lot +from . import test_delivery_shipment_scan_document_product +from . import test_delivery_shipment_unload +from . import test_delivery_shipment_loading_list +from . import test_delivery_shipment_validate diff --git a/shopfloor_delivery_shipment/tests/test_delivery_shipment_base.py b/shopfloor_delivery_shipment/tests/test_delivery_shipment_base.py new file mode 100644 index 0000000000..c4ca5cdb00 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_base.py @@ -0,0 +1,166 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo import fields + +from odoo.addons.shopfloor.tests import common + + +class DeliveryShipmentCommonCase(common.CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + result = super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref( + "shopfloor_delivery_shipment.shopfloor_menu_delivery_shipment" + ) + cls.profile = cls.env.ref("shopfloor_delivery_shipment.profile_demo_1") + # Change menu picking type to ease test (avoid to configure pick+pack+ship) + cls.wh = cls.menu.picking_type_ids.warehouse_id + cls.picking_type = cls.menu.sudo().picking_type_ids = cls.wh.out_type_id + cls.picking_type.sudo().show_entire_packs = True + cls.dock = cls.env.ref("shipment_advice.stock_dock_demo") + cls.dock.sudo().barcode = "DOCK" + cls.dock2 = cls.dock.sudo().copy({"barcode": "DOCK2"}) + return result + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + result = super().setUpClassBaseData(*args, **kwargs) + # Create 3 deliveries + cls.product_c.tracking = "lot" + cls.pickings = cls.env["stock.picking"] + for i in range(1, 4): + picking = cls._create_picking( + cls.picking_type, + lines=[ + # we'll put A and B in a single package + (cls.product_a, 10), + (cls.product_b, 10), + # C as raw product in a lot + (cls.product_c, 10), + # D as raw product + (cls.product_d, 10), + ], + ) + cls.pickings |= picking + setattr(cls, f"picking{i}", picking) + pack_moves = picking.move_ids[:2] + lot_move = picking.move_ids[2] + raw_move = picking.move_ids[3] + cls._fill_stock_for_moves(pack_moves, in_package=True) + cls._fill_stock_for_moves(lot_move, in_lot=True) + # For raw move, add stock to the current one (if any) + # so we do not use '_fill_stock_for_moves' method + cls.env["stock.quant"]._update_available_quantity( + raw_move.product_id, raw_move.location_id, raw_move.product_uom_qty + ) + picking.action_assign() + # Create a shipment advice + cls.shipment = cls._create_shipment() + return result + + @classmethod + def setUpShopfloorApp(cls): + result = super().setUpShopfloorApp() + cls.shopfloor_app.sudo().profile_ids += cls.profile + return result + + def setUp(self): + super().setUp() + self.service = self.get_service( + "delivery_shipment", profile=self.profile, menu=self.menu + ) + + @classmethod + def _create_shipment(cls): + return cls.env["shipment.advice"].create( + { + "shipment_type": "outgoing", + "dock_id": cls.dock.id, + "arrival_date": fields.Datetime.now(), + } + ) + + @classmethod + def _plan_records_in_shipment(cls, shipment_advice, records): + wiz_model = cls.env["wizard.plan.shipment"].with_context( + active_model=records._name, + active_ids=records.ids, + ) + wiz = wiz_model.create({"shipment_advice_id": shipment_advice.id}) + wiz.action_plan() + return wiz + + def _data_for_shipment_advice(self, shipment_advice): + return self.service.data.shipment_advice(shipment_advice) + + def _data_for_stock_picking(self, picking): + return self.service._data_for_stock_picking(picking) + + def assert_response_scan_dock( + self, response, message=None, confirmation_required=None + ): + data = { + "confirmation_required": confirmation_required, + } + self.assert_response( + response, next_state="scan_dock", data=data, message=message + ) + + def assert_response_scan_document( + self, + response, + shipment_advice, + picking=None, + lines_to_load=None, + location=None, + message=None, + ): + content = self.service._data_for_content_to_load_from_pickings(shipment_advice) + data = { + "shipment_advice": self._data_for_shipment_advice(shipment_advice), + "content": content, + } + if location: + data["location"] = self.service.data.location(location) + data["content"] = self.service._data_for_content_to_load_from_picking( + shipment_advice, location=location + ) + elif picking: + data["picking"] = self.service.data.picking(picking) + data["content"] = self.service._data_for_content_to_load_from_picking( + shipment_advice, picking + ) + if lines_to_load: + data["content"] = self.service._prepare_data_for_content(lines_to_load) + self.assert_response( + response, + next_state="scan_document", + data=data, + message=message, + ) + + def assert_response_loading_list(self, response, shipment_advice, message=None): + data = { + "shipment_advice": self._data_for_shipment_advice(shipment_advice), + "lading": self.service._data_for_lading(shipment_advice), + "on_dock": self.service._data_for_on_dock(shipment_advice), + } + self.assert_response( + response, + next_state="loading_list", + data=data, + message=message, + ) + + def assert_response_validate(self, response, shipment_advice, message=None): + data = { + "shipment_advice": self._data_for_shipment_advice(shipment_advice), + "lading": self.service._data_for_lading_summary(shipment_advice), + "on_dock": self.service._data_for_on_dock_summary(shipment_advice), + } + self.assert_response( + response, + next_state="validate", + data=data, + message=message, + ) diff --git a/shopfloor_delivery_shipment/tests/test_delivery_shipment_loading_list.py b/shopfloor_delivery_shipment/tests/test_delivery_shipment_loading_list.py new file mode 100644 index 0000000000..acab8b9917 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_loading_list.py @@ -0,0 +1,145 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from .test_delivery_shipment_base import DeliveryShipmentCommonCase + + +class DeliveryShipmentLoadingListCase(DeliveryShipmentCommonCase): + """Tests for '/loading_list' endpoint.""" + + def test_loading_list_wrong_id(self): + response = self.service.dispatch( + "loading_list", params={"shipment_advice_id": -1} + ) + self.assert_response_scan_dock( + response, message=self.service.msg_store.record_not_found() + ) + + def test_loading_list_shipment_planned_partially_loaded(self): + """Get the loading list of a planned shipment with part of it loaded.""" + # Plan some content in the shipment + self._plan_records_in_shipment(self.shipment, self.pickings.move_ids) + # Load a part of it + # - part of picking1 + move_line_d = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_d + ) + move_line_d._load_in_shipment(self.shipment) + # - all content of picking2 + self.picking2._load_in_shipment(self.shipment) + # - nothing from picking3 + # Get the loading list + response = self.service.dispatch( + "loading_list", params={"shipment_advice_id": self.shipment.id} + ) + self.assert_response_loading_list(response, self.shipment) + # Check returned content + lading = response["data"]["loading_list"]["lading"] + on_dock = response["data"]["loading_list"]["on_dock"] + # 'lading' key contains picking1 and picking2 + self.assertEqual( + lading, self.service.data.pickings_loaded(self.picking1 | self.picking2) + ) + # 'on_dock' key contains picking3 + self.assertEqual(on_dock, self.service.data.pickings(self.picking3)) + + def test_loading_list_shipment_planned_fully_loaded(self): + """Get the loading list of a planned shipment fully loaded.""" + # Plan some content in the shipment + self._plan_records_in_shipment(self.shipment, self.pickings.move_ids) + # Load everything + self.pickings._load_in_shipment(self.shipment) + # Get the loading list + response = self.service.dispatch( + "loading_list", params={"shipment_advice_id": self.shipment.id} + ) + self.assert_response_loading_list(response, self.shipment) + # Check returned content + lading = response["data"]["loading_list"]["lading"] + on_dock = response["data"]["loading_list"]["on_dock"] + # 'lading' key contains picking1 and picking2 + self.assertEqual(lading, self.service.data.pickings_loaded(self.pickings)) + # 'on_dock' key is empty + self.assertFalse(on_dock) + + def test_loading_list_shipment_not_planned_loaded_same_carrier_provider(self): + """Get the loading list of an unplanned shipment with some content loaded. + + All deliveries are sharing the same carrier provider. + """ + # Put the same carrier provider on all deliveries to get the unloaded + # one in the returned result + carrier1 = self.env.ref("delivery.delivery_carrier") + carrier2 = self.env.ref("delivery.delivery_local_delivery") + (carrier1 | carrier2).sudo().delivery_type = "base_on_rule" + self.picking1.carrier_id = carrier1 + self.picking2.carrier_id = self.picking3.carrier_id = carrier2 + # Load some content + # - part of picking1 + move_line_d = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_d + ) + move_line_d._load_in_shipment(self.shipment) + # - all content of picking2 + self.picking2._load_in_shipment(self.shipment) + # - nothing from picking3 + # Get the loading list + response = self.service.dispatch( + "loading_list", params={"shipment_advice_id": self.shipment.id} + ) + self.assert_response_loading_list(response, self.shipment) + # Check returned content + lading = response["data"]["loading_list"]["lading"] + on_dock = response["data"]["loading_list"]["on_dock"] + # 'lading' key contains picking1 and picking2 + self.assertEqual( + lading, self.service.data.pickings_loaded(self.picking1 | self.picking2) + ) + # 'on_dock' key contains at least picking3 + on_dock_picking_ids = [d["id"] for d in on_dock] + self.assertIn(self.picking3.id, on_dock_picking_ids) + self.assertNotIn(self.picking1.id, on_dock_picking_ids) + self.assertNotIn(self.picking2.id, on_dock_picking_ids) + + def test_loading_list_shipment_not_planned_loaded_different_carrier_provider(self): + """Get the loading list of an unplanned shipment with some content loaded. + + Deliveries loaded have the same carrier provider while the delivery still + on dock have a different one, so it won't be listed as an available + delivery to load in the current shipment. + """ + # Put the same carrier provider on loaded deliveries + carrier1 = self.env.ref("delivery.delivery_carrier") + carrier1.sudo().delivery_type = "base_on_rule" + self.picking1.carrier_id = self.picking2.carrier_id = carrier1 + # Put a different carrier provider on the unloaded one + carrier2 = self.env.ref("delivery.delivery_local_delivery") + carrier2.sudo().delivery_type = "fixed" + self.picking3.carrier_id = carrier2 + # Load some content + # - part of picking1 + move_line_d = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_d + ) + move_line_d._load_in_shipment(self.shipment) + # - all content of picking2 + self.picking2._load_in_shipment(self.shipment) + # - nothing from picking3 + # (in fact it's impossible to load it because the carrier provider + # is different) + # Get the loading list + response = self.service.dispatch( + "loading_list", params={"shipment_advice_id": self.shipment.id} + ) + self.assert_response_loading_list(response, self.shipment) + # Check returned content + lading = response["data"]["loading_list"]["lading"] + on_dock = response["data"]["loading_list"]["on_dock"] + # 'lading' key contains picking1 and picking2 + self.assertEqual( + lading, self.service.data.pickings_loaded(self.picking1 | self.picking2) + ) + # 'on_dock' key contains at least picking3 + on_dock_picking_ids = [d["id"] for d in on_dock] + self.assertNotIn(self.picking3.id, on_dock_picking_ids) + self.assertNotIn(self.picking1.id, on_dock_picking_ids) + self.assertNotIn(self.picking2.id, on_dock_picking_ids) diff --git a/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_dock.py b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_dock.py new file mode 100644 index 0000000000..fef01583e4 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_dock.py @@ -0,0 +1,108 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from .test_delivery_shipment_base import DeliveryShipmentCommonCase + + +class DeliveryShipmentScanDockCase(DeliveryShipmentCommonCase): + """Tests for '/scan_dock' endpoint.""" + + def test_scan_dock_barcode_not_found(self): + response = self.service.dispatch("scan_dock", params={"barcode": "UNKNOWN"}) + self.assert_response_scan_dock( + response, message=self.service.msg_store.barcode_not_found() + ) + + def test_scan_dock_no_shipment_in_progress(self): + response = self.service.dispatch( + "scan_dock", params={"barcode": self.dock.barcode} + ) + self.assert_response_scan_dock( + response, message=self.service.msg_store.no_shipment_in_progress() + ) + + def test_scan_dock_create_shipment_if_none(self): + self.menu.sudo().allow_shipment_advice_create = True + # First scan, a confirmation is required to create a new shipment + response = self.service.dispatch( + "scan_dock", params={"barcode": self.dock.barcode} + ) + self.assert_response_scan_dock( + response, + message=self.service.msg_store.scan_dock_again_to_confirm(self.dock), + confirmation_required=self.dock.barcode, + ) + # Second scan to confirm + response = self.service.dispatch( + "scan_dock", + params={"barcode": self.dock.barcode, "confirmation": self.dock.barcode}, + ) + new_shipment = self.env["shipment.advice"].search( + [("state", "=", "in_progress"), ("dock_id", "=", self.dock.id)], + limit=1, + order="create_date DESC", + ) + self.assert_response_scan_document(response, new_shipment) + + def test_scan_dock_create_shipment_confirmation_not_same_dock(self): + self.menu.sudo().allow_shipment_advice_create = True + # First scan, a confirmation is required to create a new shipment + response = self.service.dispatch( + "scan_dock", params={"barcode": self.dock.barcode} + ) + self.assert_response_scan_dock( + response, + message=self.service.msg_store.scan_dock_again_to_confirm(self.dock), + confirmation_required=self.dock.barcode, + ) + # Then scan a different dock barcode whith the confirmation of the previous dock + response = self.service.dispatch( + "scan_dock", + params={"barcode": self.dock2.barcode, "confirmation": self.dock.barcode}, + ) + # Return a confirmation for the last dock scanned + self.assert_response_scan_dock( + response, + message=self.service.msg_store.scan_dock_again_to_confirm(self.dock2), + confirmation_required=self.dock2.barcode, + ) + + def test_scan_dock_with_planned_content_ok(self): + self._plan_records_in_shipment(self.shipment, self.pickings) + self.shipment.action_confirm() + self.shipment.action_in_progress() + response = self.service.dispatch( + "scan_dock", params={"barcode": self.dock.barcode} + ) + self.assert_response_scan_document(response, self.shipment) + + def test_scan_dock_without_planned_content_ok(self): + self.shipment.action_confirm() + self.shipment.action_in_progress() + response = self.service.dispatch( + "scan_dock", params={"barcode": self.dock.barcode} + ) + self.assert_response_scan_document(response, self.shipment) + + def test_scan_dock_with_partially_loaded_transfers(self): + package_level = self.picking1.package_level_ids + scanned_package = package_level.package_id + # Load partially a transfer + self.shipment.action_confirm() + self.shipment.action_in_progress() + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_package.name, + }, + ) + # Scan the dock to check the content to load among partially loaded transfers + response = self.service.dispatch( + "scan_dock", params={"barcode": self.dock.barcode} + ) + lines_to_load = self.picking1.move_line_ids.filtered( + lambda l: l.package_id != scanned_package + ) + self.assert_response_scan_document( + response, self.shipment, lines_to_load=lines_to_load + ) diff --git a/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_location.py b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_location.py new file mode 100644 index 0000000000..f51d606796 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_location.py @@ -0,0 +1,150 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from .test_delivery_shipment_base import DeliveryShipmentCommonCase + + +class DeliveryShipmentScanDocumentLocationCase(DeliveryShipmentCommonCase): + """Tests for '/scan_document' endpoint when scanning a location to work from.""" + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + result = super().setUpClassBaseData(*args, **kwargs) + cls.location = cls.menu.picking_type_ids.default_location_src_id + cls.sublocation = cls.env.ref("stock.stock_location_14") + cls.badlocation = cls.env.ref("stock.stock_location_customers") + # Create a transfer from the sublocation + cls.pick_subloc = cls._create_picking( + cls.picking_type, + lines=[(cls.product_a, 10), (cls.product_b, 5), (cls.product_c, 7)], + ) + # Clearing stock in parent location insuring goods are taken from sublocation + cls._update_qty_in_location(cls.location, cls.product_a, 0) + cls._fill_stock_for_moves(cls.pick_subloc.move_ids[0], location=cls.sublocation) + cls._fill_stock_for_moves( + cls.pick_subloc.move_ids[1], in_package=True, location=cls.sublocation + ) + cls._fill_stock_for_moves( + cls.pick_subloc.move_ids[2], in_lot=True, location=cls.sublocation + ) + cls.pick_subloc.action_assign() + assert cls.pick_subloc.state == "assigned" + + return result + + def test_scan_document_location_ok_to_work_from(self): + """Scan a location allowed per the menu transfer type.""" + self._plan_records_in_shipment(self.shipment, self.picking1.move_ids) + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.sublocation.barcode, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + location=self.sublocation, + ) + + def test_scan_document_location_not_ok_to_work_from(self): + """Scan a location not allowed per the menus transfer type.""" + self._plan_records_in_shipment(self.shipment, self.picking1.move_ids) + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.badlocation.barcode, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.location_not_allowed(), + ) + + def test_scan_document_package_after_location(self): + """Scan a package when filtereing on a location. + + The filtering should stay on the location and not be on the related picking + """ + self._plan_records_in_shipment(self.shipment, self.pick_subloc.move_ids) + package_level = self.pick_subloc.package_level_ids + scanned_package = package_level.package_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_package.name, + "location_id": self.sublocation.id, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + location=self.sublocation, + ) + + def test_scan_document_lot_after_location(self): + """Scan a lot when filtereing on a location. + + The filtering should stay on the location and not be on the related picking. + """ + self._plan_records_in_shipment(self.shipment, self.pick_subloc.move_ids) + scanned_lot = self.pick_subloc.move_line_ids.lot_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_lot.name, + "location_id": self.sublocation.id, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + location=self.sublocation, + ) + + def test_unload_package_keep_location(self): + """Check the filtered location is kept when unloading a package.""" + package_level = self.pick_subloc.package_level_ids + # Load the package level at first + package_level._load_in_shipment(self.shipment) + # Then unload it + response = self.service.dispatch( + "unload_package_level", + params={ + "shipment_advice_id": self.shipment.id, + "package_level_id": package_level.id, + "location_id": self.sublocation.id, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + location=self.sublocation, + ) + + def test_unload_move_line_keep_location(self): + """Check the filtered location is kept when unloading a move line.""" + move_line = self.picking1.move_ids.filtered( + lambda m: m.product_id == self.product_c + ).move_line_ids + # Load the move line at first + move_line._load_in_shipment(self.shipment) + # Then unload it + response = self.service.dispatch( + "unload_move_line", + params={ + "shipment_advice_id": self.shipment.id, + "move_line_id": move_line.id, + "location_id": self.sublocation.id, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + move_line.picking_id, + location=self.sublocation, + ) diff --git a/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_lot.py b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_lot.py new file mode 100644 index 0000000000..9148bf2b7e --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_lot.py @@ -0,0 +1,257 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from .test_delivery_shipment_base import DeliveryShipmentCommonCase + + +class DeliveryShipmentScanDocumentLotCase(DeliveryShipmentCommonCase): + """Tests for '/scan_document' endpoint when scanning a lot.""" + + def test_scan_document_shipment_planned_lot_not_planned(self): + """Scan a lot not planned in the shipment advice. + + The shipment advice has some content planned but the user scans an + unrelated one, returning an error. + """ + self._plan_records_in_shipment(self.shipment, self.picking1.move_ids) + scanned_lot = self.picking2.move_ids_without_package.move_line_ids.lot_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_lot.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.lot_not_planned_in_shipment( + scanned_lot, self.shipment + ), + ) + + def test_scan_document_shipment_planned_lot_planned_fully_loaded(self): + """Scan a lot planned in the shipment advice. + + The shipment advice has some content planned and the user scans an + expected one, loading the lot and returning the loading list of the + shipment as it is now fully loaded. + """ + move_line = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_c + ) + self._plan_records_in_shipment(self.shipment, move_line.move_id) + scanned_lot = move_line.lot_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_lot.name, + }, + ) + self.assert_response_loading_list( + response, + self.shipment, + message=self.service.msg_store.shipment_planned_content_fully_loaded(), + ) + # Check lot status + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + # Check returned content + lading = response["data"]["loading_list"]["lading"] + on_dock = response["data"]["loading_list"]["on_dock"] + # 'lading' key contains the related delivery + self.assertEqual(lading, self.service.data.pickings_loaded(self.picking1)) + # 'on_dock' key is empty as there is no other delivery planned + self.assertFalse(on_dock) + + def test_scan_document_shipment_planned_lot_planned_partially_loaded(self): + """Scan a lot planned in the shipment advice. + + The shipment advice has several content planned and the user scans an + expected one, loading the lot and returning the planned content + of this delivery for the current shipment (shipment partially loaded). + """ + planned_moves = self.picking1.move_ids_without_package + self._plan_records_in_shipment(self.shipment, planned_moves) + move_line = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_c + ) + scanned_lot = move_line.lot_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_lot.name, + }, + ) + self.assert_response_scan_document(response, self.shipment, self.picking1) + # Check lot status + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + self.assertIn(location_src, content) + # 'move_lines' key contains the planned content including the lot scanned + self.assertEqual( + content[location_src]["move_lines"], + self.service.data.move_ids(planned_moves.move_line_ids), + ) + # 'package_levels' key doesn't exist (not planned for this shipment) + self.assertNotIn("package_levels", content[location_src]) + + def test_scan_document_shipment_not_planned_lot_not_planned(self): + """Scan a lot not planned for a shipment not planned. + + Load the lot and return the available content to load/unload + of the related delivery. + """ + move_line = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_c + ) + scanned_lot = move_line.lot_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_lot.name, + }, + ) + self.assert_response_scan_document(response, self.shipment, self.picking1) + # Check lot status + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + self.assertIn(location_src, content) + # 'move_ids' key contains the lot scanned and other lines not yet + # loaded from the same delivery + self.assertEqual( + content[location_src]["move_ids"], + self.service.data.move_ids( + self.picking1.move_ids_without_package.move_line_ids + ), + ) + # 'package_levels' key contains the package available from the same delivery + self.assertEqual( + content[location_src]["package_levels"], + self.service.data.package_levels(self.picking1.package_level_ids), + ) + + def test_scan_document_lot_already_loaded(self): + """Scan a lot already loaded in the current shipment. + + The second time a lot is scanned an warning is returned saying that + the lot has already been loaded. + """ + move_line = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_c + ) + scanned_lot = move_line.lot_id + # First scan + self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_lot.name, + }, + ) + # Second scan + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_lot.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + self.picking1, + message=self.service.msg_store.lot_already_loaded_in_shipment( + scanned_lot, + self.shipment, + ), + ) + # Check lot status + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + self.assertIn(location_src, content) + # 'move_lines' key contains the lot scanned and other lines not yet + # loaded from the same delivery + self.assertEqual( + content[location_src]["move_lines"], + self.service.data.move_ids( + self.picking1.move_ids_without_package.move_line_ids + ), + ) + # 'package_levels' key contains the package available from the same delivery + self.assertEqual( + content[location_src]["package_levels"], + self.service.data.package_levels(self.picking1.package_level_ids), + ) + + def test_scan_document_shipment_not_planned_lot_planned(self): + """Scan an already planned lot in the shipment not planned. + + Returns an error saying that the lot could not be loaded. + """ + move_line = self.picking1.move_ids_without_package.move_line_ids + scanned_lot = move_line.lot_id + # Plan the lot in a another shipment + new_shipment = self._create_shipment() + self._plan_records_in_shipment(new_shipment, move_line.move_id) + # Scan the lot: an error is returned as this lot has already + # been planned in another shipment + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_lot.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.unable_to_load_lot_in_shipment( + scanned_lot, self.shipment + ), + ) + + def test_scan_document_lot_number_shared_with_multiple_products(self): + """Scan a lot whose the number is shared with different products. + + One is a relevant product (ready to be loaded in the shipment) and + another one is a completely unrelated product for the current + shipment/operations to process. + + The scan should then load the relevant lot. + """ + # Prepare data + product_unrelated = self.product_c.create( + {"name": "UNRELATED PRODUCT", "default_code": "UNRELATED"} + ) + move_line = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_c + ) + lot = move_line.lot_id + lot_unrelated = lot.copy({"product_id": product_unrelated.id}) + self.assertEqual(lot.name, lot_unrelated.name) + self.assertNotEqual(lot.product_id, lot_unrelated.product_id) + # Check that lot number is shared as expected + available_lots = self.service._actions_for("search").lot_from_scan( + lot.name, limit=None + ) + self.assertEqual(available_lots.product_id, self.product_c | product_unrelated) + # Scan the lot number + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": lot.name, + }, + ) + self.assert_response_scan_document(response, self.shipment, self.picking1) + # Check lot status + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + self.assertEqual(move_line.lot_id, lot) diff --git a/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_package.py b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_package.py new file mode 100644 index 0000000000..89627f9103 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_package.py @@ -0,0 +1,176 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from .test_delivery_shipment_base import DeliveryShipmentCommonCase + + +class DeliveryShipmentScanDocumentPackageCase(DeliveryShipmentCommonCase): + """Tests for '/scan_document' endpoint when scanning a package.""" + + def test_scan_document_shipment_planned_package_not_planned(self): + """Scan a package not planned in the shipment advice. + + The shipment advice has some content planned but the user scans an + unrelated one, returning an error. + """ + self._plan_records_in_shipment(self.shipment, self.picking1.move_ids) + scanned_package = self.picking2.package_level_ids.package_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_package.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.package_not_planned_in_shipment( + scanned_package, self.shipment + ), + ) + + def test_scan_document_shipment_planned_package_planned(self): + """Scan a package planned in the shipment advice. + + The shipment advice has some content planned and the user scans an + expected one, loading the package and returning the loading list of the + shipment as it is now fully loaded. + """ + package_level = self.picking1.package_level_ids + self._plan_records_in_shipment( + self.shipment, package_level.move_line_ids.move_id + ) + scanned_package = package_level.package_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_package.name, + }, + ) + self.assert_response_loading_list( + response, + self.shipment, + message=self.service.msg_store.shipment_planned_content_fully_loaded(), + ) + # Check package level status + self.assertTrue(package_level.is_done) + # Check returned content + lading = response["data"]["loading_list"]["lading"] + on_dock = response["data"]["loading_list"]["on_dock"] + # 'lading' key contains the related delivery + self.assertEqual(lading, self.service.data.pickings_loaded(self.picking1)) + # 'on_dock' key is empty as there is no other delivery planned + self.assertFalse(on_dock) + + def test_scan_document_shipment_not_planned_package_not_planned(self): + """Scan a package not planned for a shipment not planned. + + Load the package and return the available content to load/unload + of the related delivery. + """ + package_level = self.picking1.package_level_ids + scanned_package = package_level.package_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_package.name, + }, + ) + self.assert_response_scan_document(response, self.shipment, self.picking1) + # Check package level status + self.assertTrue(package_level.is_done) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + self.assertIn(location_src, content) + # 'move_lines' key contains the lines available from the same delivery + self.assertEqual( + content[location_src]["move_lines"], + self.service.data.move_ids(self.picking1.move_line_ids_without_package), + ) + # 'package_levels' key contains the package which has been loaded + self.assertEqual( + content[location_src]["package_levels"], + self.service.data.package_levels(package_level), + ) + + def test_scan_document_package_already_loaded(self): + """Scan a package already loaded in the current shipment. + + The second time a package is scanned an warning is returned saying that + the package has already been loaded. + """ + package_level = self.picking1.package_level_ids + scanned_package = package_level.package_id + # First scan + self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_package.name, + }, + ) + # Second scan + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_package.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + self.picking1, + message=self.service.msg_store.package_already_loaded_in_shipment( + scanned_package, + self.shipment, + ), + ) + # Check package level status + self.assertTrue(package_level.is_done) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + self.assertIn(location_src, content) + # 'move_lines' key contains the only one product without package + self.assertEqual( + content[location_src]["move_lines"], + self.service.data.move_ids(self.picking1.move_line_ids_without_package), + ) + # 'package_levels' key contains the package which has been loaded + self.assertEqual( + content[location_src]["package_levels"], + self.service.data.package_levels(package_level), + ) + + def test_scan_document_shipment_not_planned_package_planned(self): + """Scan an already planned package in the shipment not planned. + + Returns an error saying that the package could not be loaded. + """ + package_level = self.picking1.package_level_ids + scanned_package = package_level.package_id + # Plan the package in a another shipment + new_shipment = self._create_shipment() + self._plan_records_in_shipment( + new_shipment, package_level.move_line_ids.move_id + ) + # Scan the package: an error is returned as this package has already + # been planned in another shipment + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_package.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.unable_to_load_package_in_shipment( + scanned_package, self.shipment + ), + ) diff --git a/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_picking.py b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_picking.py new file mode 100644 index 0000000000..230127ca25 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_picking.py @@ -0,0 +1,266 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from .test_delivery_shipment_base import DeliveryShipmentCommonCase + + +class DeliveryShipmentScanDocumentPickingCase(DeliveryShipmentCommonCase): + """Tests for '/scan_document' endpoint when scanning a delivery.""" + + def test_scan_document_barcode_not_found(self): + response = self.service.dispatch( + "scan_document", + params={"shipment_advice_id": self.shipment.id, "barcode": "UNKNOWN"}, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.barcode_not_found(), + ) + + def test_scan_document_shipment_planned_picking_not_planned(self): + """Scan a delivery not planned in the shipment advice. + + The shipment advice has some deliveries planned but the user scans an + unrelated one, returning an error. + """ + self._plan_records_in_shipment(self.shipment, self.picking1 | self.picking2) + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.picking3.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.picking_not_planned_in_shipment( + self.picking3, self.shipment + ), + ) + + def test_scan_document_shipment_planned_picking_planned(self): + """Scan a delivery planned in the shipment advice. + + The shipment advice has some deliveries planned and the user scans an + expected one, returning the planned content of this delivery for the + current shipment. + """ + self._plan_records_in_shipment(self.shipment, self.picking1) + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.picking1.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + self.picking1, + ) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + self.assertIn(location_src, content) + # 'move_lines' key contains the only one product without package + self.assertEqual( + content[location_src]["move_lines"], + self.service.data.move_ids(self.picking1.move_line_ids_without_package), + ) + # 'package_levels' key contains the packages + self.assertEqual( + content[location_src]["package_levels"], + self.service.data.package_levels(self.picking1.package_level_ids), + ) + + def test_scan_document_shipment_planned_picking_partially_planned(self): + """Scan a delivery partially planned in the shipment advice. + + The shipment advice has some deliveries planned and the user scans an + expected one, returning the planned content of this delivery for the + current shipment. + """ + self._plan_records_in_shipment( + self.shipment, self.picking1.move_ids_without_package + ) + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.picking1.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + self.picking1, + ) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + self.assertIn(location_src, content) + # 'move_lines' key contains the only one product without package + self.assertEqual( + content[location_src]["move_lines"], + self.service.data.move_ids(self.picking1.move_line_ids_without_package), + ) + # 'package_levels' key doesn't exist (not planned for this shipment) + self.assertNotIn("package_levels", content[location_src]) + + def test_scan_document_shipment_not_planned_picking_carrier_unrelated(self): + """Scan a delivery whose the carrier doesn't belong to the related + carriers of the shipment (if any). + + This is only relevant for shipment without planned content. + """ + self.picking1.carrier_id = self.env.ref("delivery.delivery_carrier") + self.picking1.carrier_id.sudo().delivery_type = "base_on_rule" + self.picking2.carrier_id = self.env.ref("delivery.delivery_local_delivery") + self.picking2.carrier_id.sudo().delivery_type = "fixed" + # Load the first delivery in the shipment + self.picking1._load_in_shipment(self.shipment) + # Scan the second which has a different carrier => error + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.picking2.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.carrier_not_allowed_by_shipment( + self.picking2 + ), + ) + + def test_scan_document_shipment_not_planned_picking_not_planned(self): + """Scan a delivery without content planned for a shipment not planned. + + Returns the full content of the scanned delivery. + """ + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.picking1.name, + }, + ) + self.assert_response_scan_document(response, self.shipment, self.picking1) + + def test_scan_document_shipment_not_planned_picking_fully_planned(self): + """Scan an already fully planned delivery for a shipment not planned. + + Returns an error saying that the delivery can not be loaded. + """ + # Plan the whole delivery in a another shipment + new_shipment = self._create_shipment() + self._plan_records_in_shipment(new_shipment, self.picking1) + # Scan the delivery: get an error + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.picking1.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.no_delivery_content_to_load(self.picking1), + ) + + def test_scan_document_shipment_not_planned_picking_partially_planned(self): + """Scan a delivery with some content planned for a shipment not planned. + + Returns the not planned content of the scanned delivery. + """ + # Plan the move without package in a another shipment + new_shipment = self._create_shipment() + self._plan_records_in_shipment( + new_shipment, self.picking1.move_ids_without_package + ) + # Scan the delivery: only the not planned content is returned (i.e. the + # remaining package here). + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.picking1.name, + }, + ) + self.assert_response_scan_document(response, self.shipment, self.picking1) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + # 'move_lines' key doesn't exist (planned in another shipment) + self.assertNotIn("move_lines", content[location_src]) + # 'package_levels' key contains the not planned packages + self.assertEqual( + content[location_src]["package_levels"], + self.service.data.package_levels(self.picking1.package_level_ids), + ) + + def test_scan_document_shipment_not_planned_picking_without_content_to_load(self): + """Scan a delivery without content to load for a shipment not planned. + + Returns an error. + """ + # Load the whole delivery in a another shipment + new_shipment = self._create_shipment() + self.picking1._load_in_shipment(new_shipment) + # Scan the delivery: an error is returned as no more content can be + # loaded from this delivery + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.picking1.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.no_delivery_content_to_load(self.picking1), + ) + + def test_scan_document_shipment_not_planned_picking_partially_loaded(self): + """Scan a delivery with content partially loaded in a shipment not planned. + + Returns the content which is: + - not planned + - not loaded (in any shipment) + - already loaded in the current shipment + """ + # Load the move without package in a another shipment + new_shipment = self._create_shipment() + self.picking1.move_ids_without_package.move_line_ids._load_in_shipment( + new_shipment + ) + # Load the package level in the current shipment + self.picking1.package_level_ids._load_in_shipment(self.shipment) + # Scan the delivery: returns the already loaded package levels as content + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": self.picking1.name, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + self.picking1, + ) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + # 'move_lines' key doesn't exist (loaded in another shipment) + self.assertNotIn("move_lines", content[location_src]) + # 'package_levels' key contains the already loaded package levels + self.assertEqual( + content[location_src]["package_levels"], + self.service.data.package_levels(self.picking1.package_level_ids), + ) diff --git a/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_product.py b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_product.py new file mode 100644 index 0000000000..3a9a3e83cf --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_product.py @@ -0,0 +1,290 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from .test_delivery_shipment_base import DeliveryShipmentCommonCase + + +class DeliveryShipmentScanDocumentProductCase(DeliveryShipmentCommonCase): + """Tests for '/scan_document' endpoint when scanning a product.""" + + def test_scan_document_product_without_picking(self): + """Scan a product without having scanned the related operation previously. + + Returns an error telling the user to first scan an operation. + """ + planned_move = self.picking1.move_ids.filtered( + lambda m: m.product_id == self.product_c + ) + self._plan_records_in_shipment(self.shipment, planned_move) + scanned_product = self.product_d + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "barcode": scanned_product.barcode, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + message=self.service.msg_store.scan_operation_first(), + ) + + def test_scan_document_shipment_planned_product_not_planned(self): + """Scan a product not planned in the shipment advice. + + The shipment advice has some content planned but the user scans an + unrelated one, returning an error. + """ + planned_move = self.picking1.move_ids.filtered( + lambda m: m.product_id == self.product_c + ) + self._plan_records_in_shipment(self.shipment, planned_move) + scanned_product = self.product_d + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "picking_id": self.picking1.id, + "barcode": scanned_product.barcode, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + picking=self.picking1, + message=self.service.msg_store.product_not_planned_in_shipment( + scanned_product, self.shipment + ), + ) + + def test_scan_document_shipment_planned_product_planned(self): + """Scan a product planned in the shipment advice. + + The shipment advice has some content planned and the user scans an + expected one, loading the product and returning the loading list of the + shipment as it is now fully loaded. + """ + move_line = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_d + ) + self._plan_records_in_shipment(self.shipment, move_line.move_id) + scanned_product = move_line.product_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "picking_id": self.picking1.id, + "barcode": scanned_product.barcode, + }, + ) + self.assert_response_loading_list( + response, + self.shipment, + message=self.service.msg_store.shipment_planned_content_fully_loaded(), + ) + # Check product line status + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + # Check returned content + lading = response["data"]["loading_list"]["lading"] + on_dock = response["data"]["loading_list"]["on_dock"] + # 'lading' key contains the related delivery + self.assertEqual(lading, self.service.data.pickings_loaded(self.picking1)) + # 'on_dock' key is empty as there is no other delivery planned + self.assertFalse(on_dock) + + def test_scan_document_shipment_not_planned_product_not_planned(self): + """Scan a product not planned for a shipment not planned. + + Load the product and return the available content to load/unload + of the related delivery. + """ + move_line = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_d + ) + scanned_product = move_line.product_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "picking_id": self.picking1.id, + "barcode": scanned_product.barcode, + }, + ) + self.assert_response_scan_document(response, self.shipment, self.picking1) + # Check product line status + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + self.assertIn(location_src, content) + # 'move_lines' key contains the product scanned and other lines not + # yet loaded from the same delivery + self.assertEqual( + content[location_src]["move_lines"], + self.service.data.move_ids( + self.picking1.move_ids_without_package.move_line_ids + ), + ) + # 'package_levels' key contains the package available from the same delivery + self.assertEqual( + content[location_src]["package_levels"], + self.service.data.package_levels(self.picking1.package_level_ids), + ) + + def test_scan_document_product_already_loaded(self): + """Scan a product already loaded in the current shipment. + + The second time a product is scanned a warning is returned saying that + the product has already been loaded. + """ + move_line = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_d + ) + scanned_product = move_line.product_id + # First scan + self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "picking_id": self.picking1.id, + "barcode": scanned_product.barcode, + }, + ) + # Second scan + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "picking_id": self.picking1.id, + "barcode": scanned_product.barcode, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + self.picking1, + message=self.service.msg_store.product_already_loaded_in_shipment( + scanned_product, + self.shipment, + ), + ) + # Check product line status + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + # Check returned content + location_src = self.picking_type.default_location_src_id.name + content = response["data"]["scan_document"]["content"] + self.assertIn(location_src, content) + # 'move_lines' key contains the product scanned and other lines not + # yet loaded from the same delivery + self.assertEqual( + content[location_src]["move_lines"], + self.service.data.move_ids( + self.picking1.move_ids_without_package.move_line_ids + ), + ) + # 'package_levels' key contains the package available from the same delivery + self.assertEqual( + content[location_src]["package_levels"], + self.service.data.package_levels(self.picking1.package_level_ids), + ) + + def test_scan_document_shipment_not_planned_product_planned(self): + """Scan an already planned product in the shipment not planned. + + Returns an error saying that the product could not be loaded. + """ + # Grab all lines related to product to plan + move_lines = self.pickings.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_d + ) + scanned_product = self.product_d + # Plan all the product moves in a another shipment + new_shipment = self._create_shipment() + self._plan_records_in_shipment(new_shipment, move_lines.move_id) + # Scan the product: an error is returned as these product lines have + # already been planned in another shipment + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "picking_id": self.picking1.id, + "barcode": scanned_product.barcode, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + picking=self.picking1, + message=self.service.msg_store.unable_to_load_product_in_shipment( + scanned_product, self.shipment + ), + ) + + def test_scan_document_product_owned_by_package(self): + """Scan a product owned by a package.. + + Returns an error telling the user to scan the relevant packages. + """ + move_line = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_a + ) + scanned_product = move_line.product_id + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "picking_id": self.picking1.id, + "barcode": scanned_product.barcode, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + self.picking1, + message=self.service.msg_store.product_owned_by_packages( + move_line.package_level_id.package_id + ), + ) + + def test_scan_document_product_owned_by_lots(self): + """Scan a product owned by several lots. + + Returns an error telling the user to scan the relevant lots. + """ + self.pickings.do_unreserve() + scanned_product = self.product_d + scanned_product.tracking = "lot" + move = self.picking1.move_ids.filtered( + lambda m: m.product_id == scanned_product + ) + # Put two lots in stock + lot1 = self.env["stock.lot"].create( + {"product_id": scanned_product.id, "company_id": self.env.company.id} + ) + lot2 = self.env["stock.lot"].create( + {"product_id": scanned_product.id, "company_id": self.env.company.id} + ) + self.env["stock.quant"]._update_available_quantity( + scanned_product, move.location_id, 5, lot_id=lot1 + ) + self.env["stock.quant"]._update_available_quantity( + scanned_product, move.location_id, 5, lot_id=lot2 + ) + # Reserve them for a delivery and scan the related product + self.picking1.action_assign() + move_lines = move.move_line_ids + self.assertTrue(move_lines.lot_id) + response = self.service.dispatch( + "scan_document", + params={ + "shipment_advice_id": self.shipment.id, + "picking_id": self.picking1.id, + "barcode": scanned_product.barcode, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + self.picking1, + message=self.service.msg_store.product_owned_by_lots(move_lines.lot_id), + ) diff --git a/shopfloor_delivery_shipment/tests/test_delivery_shipment_unload.py b/shopfloor_delivery_shipment/tests/test_delivery_shipment_unload.py new file mode 100644 index 0000000000..5c08059563 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_unload.py @@ -0,0 +1,79 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from .test_delivery_shipment_base import DeliveryShipmentCommonCase + + +class DeliveryShipmentUnloadCase(DeliveryShipmentCommonCase): + """Tests for the following endpoints: + + - /unload_move_line + - /unload_package_level + """ + + def test_unload_move_line_wrong_id(self): + """Try to unload a move line which doesn't exist (wrong ID).""" + response = self.service.dispatch( + "unload_move_line", + params={ + "shipment_advice_id": self.shipment.id, + "move_line_id": -1, # Wrong ID + }, + ) + self.assert_response_scan_dock( + response, + message=self.service.msg_store.record_not_found(), + ) + + def test_unload_move_line_ok(self): + """Unload a move line and returns the content of the related delivery.""" + move_line = self.picking1.move_ids.filtered( + lambda m: m.product_id == self.product_c + ).move_line_ids + # Load the move line at first + move_line._load_in_shipment(self.shipment) + # Then unload it + response = self.service.dispatch( + "unload_move_line", + params={ + "shipment_advice_id": self.shipment.id, + "move_line_id": move_line.id, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + move_line.picking_id, + ) + + def test_unload_package_level_wrong_id(self): + """Try to unload a package level which doesn't exist (wrong ID).""" + response = self.service.dispatch( + "unload_package_level", + params={ + "shipment_advice_id": self.shipment.id, + "package_level_id": -1, # Wrong ID + }, + ) + self.assert_response_scan_dock( + response, + message=self.service.msg_store.record_not_found(), + ) + + def test_unload_package_level_ok(self): + """Unload a package level and returns the content of the related delivery.""" + package_level = self.picking1.package_level_ids + # Load the package level at first + package_level._load_in_shipment(self.shipment) + # Then unload it + response = self.service.dispatch( + "unload_package_level", + params={ + "shipment_advice_id": self.shipment.id, + "package_level_id": package_level.id, + }, + ) + self.assert_response_scan_document( + response, + self.shipment, + package_level.picking_id, + ) diff --git a/shopfloor_delivery_shipment/tests/test_delivery_shipment_validate.py b/shopfloor_delivery_shipment/tests/test_delivery_shipment_validate.py new file mode 100644 index 0000000000..a0e51539ae --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_validate.py @@ -0,0 +1,148 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from .test_delivery_shipment_base import DeliveryShipmentCommonCase + + +class DeliveryShipmentValidateCase(DeliveryShipmentCommonCase): + """Tests for '/validate' endpoint.""" + + def test_validate_wrong_id(self): + response = self.service.dispatch("validate", params={"shipment_advice_id": -1}) + self.assert_response_scan_dock( + response, message=self.service.msg_store.record_not_found() + ) + + def test_validate_shipment_planned_partially_loaded(self): + """Validate a planned shipment with part of it loaded.""" + # Plan 3 deliveries in the shipment + self._plan_records_in_shipment(self.shipment, self.pickings.move_ids) + self.shipment.action_confirm() + self.shipment.action_in_progress() + # Load a part of it + # - part of picking1 + move_line_d = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_d + ) + move_line_d._load_in_shipment(self.shipment) + # - all content of picking2 + self.picking2._load_in_shipment(self.shipment) + # - nothing from picking3 + # Get the summary + response = self.service.dispatch( + "validate", params={"shipment_advice_id": self.shipment.id} + ) + self.assert_response_validate(response, self.shipment) + # Check returned content + lading = response["data"]["validate"]["lading"] + on_dock = response["data"]["validate"]["on_dock"] + # 'lading' key contains loaded goods + self.assertEqual(lading["loaded_pickings_count"], 2) + self.assertEqual(lading["loaded_packages_count"], 1) + self.assertEqual(lading["loaded_bulk_lines_count"], 3) + self.assertEqual(lading["total_packages_count"], 2) + self.assertEqual(lading["total_bulk_lines_count"], 4) + # 'on_dock' key contains picking3 + self.assertEqual(on_dock["total_pickings_count"], 1) + self.assertEqual(on_dock["total_packages_count"], 1) + self.assertEqual(on_dock["total_bulk_lines_count"], 2) + # Validate the shipment + response = self.service.dispatch( + "validate", + params={"shipment_advice_id": self.shipment.id, "confirmation": True}, + ) + self.assert_response_scan_dock( + response, + message=self.service.msg_store.shipment_validated(self.shipment), + ) + # Check data + self.assertEqual(self.picking1.state, self.picking2.state, "done") + self.assertEqual(self.picking3.state, "assigned") + self.assertTrue(self.picking1.backorder_ids) + self.assertFalse(self.picking2.backorder_ids) + + def test_validate_shipment_planned_fully_loaded(self): + """Validate a planned shipment fully loaded.""" + # Plan 3 deliveries in the shipment + self._plan_records_in_shipment(self.shipment, self.pickings.move_ids) + self.shipment.action_confirm() + self.shipment.action_in_progress() + # Load everything + self.pickings._load_in_shipment(self.shipment) + # Get the summary + response = self.service.dispatch( + "validate", params={"shipment_advice_id": self.shipment.id} + ) + self.assert_response_validate(response, self.shipment) + # Check returned content + lading = response["data"]["validate"]["lading"] + on_dock = response["data"]["validate"]["on_dock"] + # 'lading' key contains loaded goods + self.assertEqual(lading["loaded_pickings_count"], 3) + self.assertEqual(lading["loaded_packages_count"], 3) + self.assertEqual(lading["total_packages_count"], 3) + self.assertEqual(lading["loaded_bulk_lines_count"], 6) + self.assertEqual(lading["total_bulk_lines_count"], 6) + # 'on_dock' key is empty as everything has been loaded + self.assertEqual(on_dock["total_pickings_count"], 0) + self.assertEqual(on_dock["total_packages_count"], 0) + self.assertEqual(on_dock["total_bulk_lines_count"], 0) + # Validate the shipment + response = self.service.dispatch( + "validate", + params={"shipment_advice_id": self.shipment.id, "confirmation": True}, + ) + self.assert_response_scan_dock( + response, + message=self.service.msg_store.shipment_validated(self.shipment), + ) + # Check data + self.assertTrue(all([s == "done" for s in self.pickings.mapped("state")])) + self.assertFalse(self.picking1.backorder_ids) + self.assertFalse(self.picking2.backorder_ids) + + def test_validate_shipment_not_planned_loaded(self): + """Validate a unplanned shipment with some content loaded.""" + self.shipment.action_confirm() + self.shipment.action_in_progress() + # Load some content + # - part of picking1 + move_line_d = self.picking1.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_d + ) + move_line_d._load_in_shipment(self.shipment) + # - all content of picking2 + self.picking2._load_in_shipment(self.shipment) + # - nothing from picking3 + # Get the summary + response = self.service.dispatch( + "validate", params={"shipment_advice_id": self.shipment.id} + ) + self.assert_response_validate(response, self.shipment) + # Check returned content + lading = response["data"]["validate"]["lading"] + on_dock = response["data"]["validate"]["on_dock"] + # 'lading' key contains loaded goods + self.assertEqual(lading["loaded_pickings_count"], 2) + self.assertEqual(lading["loaded_packages_count"], 1) + self.assertEqual(lading["total_packages_count"], 2) + self.assertEqual(lading["loaded_bulk_lines_count"], 3) + self.assertEqual(lading["total_bulk_lines_count"], 4) + # 'on_dock' key contains picking3 (at least, as there is others + # existing deliveries in the demo data) + self.assertTrue(on_dock["total_pickings_count"] >= 1) + self.assertTrue(on_dock["total_packages_count"] >= 1) + self.assertTrue(on_dock["total_bulk_lines_count"] >= 2) + # Validate the shipment + response = self.service.dispatch( + "validate", + params={"shipment_advice_id": self.shipment.id, "confirmation": True}, + ) + self.assert_response_scan_dock( + response, + message=self.service.msg_store.shipment_validated(self.shipment), + ) + # Check data + self.assertEqual(self.picking1.state, self.picking2.state, "done") + self.assertEqual(self.picking3.state, "assigned") + self.assertTrue(self.picking1.backorder_ids) + self.assertFalse(self.picking2.backorder_ids) diff --git a/shopfloor_delivery_shipment/views/shopfloor_menu.xml b/shopfloor_delivery_shipment/views/shopfloor_menu.xml new file mode 100644 index 0000000000..5022fe6a8b --- /dev/null +++ b/shopfloor_delivery_shipment/views/shopfloor_menu.xml @@ -0,0 +1,22 @@ + + + + + + shopfloor.menu + + + + + + + + + + + +