From 273e75d7486f66954c35dd9ffafdcebf439e38de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Thu, 1 Jul 2021 11:38:31 +0200 Subject: [PATCH 01/35] [ADD] shopfloor_delivery_shipment Shopfloor scenario to manage the delivery process based on shipment advices. It is based on the `shipment_advice` module. --- shopfloor_delivery_shipment/README.rst | 95 ++ shopfloor_delivery_shipment/__init__.py | 3 + shopfloor_delivery_shipment/__manifest__.py | 22 + .../actions/__init__.py | 4 + shopfloor_delivery_shipment/actions/data.py | 59 ++ .../actions/message.py | 160 ++++ shopfloor_delivery_shipment/actions/schema.py | 97 ++ shopfloor_delivery_shipment/actions/search.py | 15 + .../data/shopfloor_scenario_data.xml | 11 + .../demo/shopfloor_menu_demo.xml | 14 + .../docs/delivery_shipment_diag_seq.plantuml | 49 + .../docs/delivery_shipment_diag_seq.png | Bin 0 -> 71530 bytes shopfloor_delivery_shipment/docs/oca_logo.png | Bin 0 -> 3297 bytes .../models/__init__.py | 1 + .../models/shopfloor_menu.py | 24 + .../readme/CONTRIBUTORS.rst | 7 + .../readme/CREDITS.rst | 4 + .../readme/DESCRIPTION.rst | 1 + .../services/__init__.py | 1 + .../services/delivery_shipment.py | 859 ++++++++++++++++++ .../static/description/index.html | 438 +++++++++ shopfloor_delivery_shipment/tests/__init__.py | 9 + .../tests/test_delivery_shipment_base.py | 131 +++ .../test_delivery_shipment_loading_list.py | 145 +++ .../tests/test_delivery_shipment_scan_dock.py | 60 ++ ...est_delivery_shipment_scan_document_lot.py | 218 +++++ ...delivery_shipment_scan_document_package.py | 175 ++++ ...delivery_shipment_scan_document_picking.py | 258 ++++++ ...delivery_shipment_scan_document_product.py | 287 ++++++ .../tests/test_delivery_shipment_unload.py | 73 ++ .../tests/test_delivery_shipment_validate.py | 145 +++ .../views/shopfloor_menu.xml | 22 + 32 files changed, 3387 insertions(+) create mode 100644 shopfloor_delivery_shipment/README.rst create mode 100644 shopfloor_delivery_shipment/__init__.py create mode 100644 shopfloor_delivery_shipment/__manifest__.py create mode 100644 shopfloor_delivery_shipment/actions/__init__.py create mode 100644 shopfloor_delivery_shipment/actions/data.py create mode 100644 shopfloor_delivery_shipment/actions/message.py create mode 100644 shopfloor_delivery_shipment/actions/schema.py create mode 100644 shopfloor_delivery_shipment/actions/search.py create mode 100644 shopfloor_delivery_shipment/data/shopfloor_scenario_data.xml create mode 100644 shopfloor_delivery_shipment/demo/shopfloor_menu_demo.xml create mode 100644 shopfloor_delivery_shipment/docs/delivery_shipment_diag_seq.plantuml create mode 100644 shopfloor_delivery_shipment/docs/delivery_shipment_diag_seq.png create mode 100644 shopfloor_delivery_shipment/docs/oca_logo.png create mode 100644 shopfloor_delivery_shipment/models/__init__.py create mode 100644 shopfloor_delivery_shipment/models/shopfloor_menu.py create mode 100644 shopfloor_delivery_shipment/readme/CONTRIBUTORS.rst create mode 100644 shopfloor_delivery_shipment/readme/CREDITS.rst create mode 100644 shopfloor_delivery_shipment/readme/DESCRIPTION.rst create mode 100644 shopfloor_delivery_shipment/services/__init__.py create mode 100644 shopfloor_delivery_shipment/services/delivery_shipment.py create mode 100644 shopfloor_delivery_shipment/static/description/index.html create mode 100644 shopfloor_delivery_shipment/tests/__init__.py create mode 100644 shopfloor_delivery_shipment/tests/test_delivery_shipment_base.py create mode 100644 shopfloor_delivery_shipment/tests/test_delivery_shipment_loading_list.py create mode 100644 shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_dock.py create mode 100644 shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_lot.py create mode 100644 shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_package.py create mode 100644 shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_picking.py create mode 100644 shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_product.py create mode 100644 shopfloor_delivery_shipment/tests/test_delivery_shipment_unload.py create mode 100644 shopfloor_delivery_shipment/tests/test_delivery_shipment_validate.py create mode 100644 shopfloor_delivery_shipment/views/shopfloor_menu.xml diff --git a/shopfloor_delivery_shipment/README.rst b/shopfloor_delivery_shipment/README.rst new file mode 100644 index 0000000000..66808602fb --- /dev/null +++ b/shopfloor_delivery_shipment/README.rst @@ -0,0 +1,95 @@ +========================================= +Shopfloor - Delivery with shipment advice +========================================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/13.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-13-0/wms-13-0-shopfloor_delivery_shipment + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/285/13.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Shopfloor scenario to manage the delivery process based on shipment advices. + +**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 smashing 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 + +Current `maintainer `__: + +|maintainer-sebalix| + +This module is part of the `OCA/wms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor_delivery_shipment/__init__.py b/shopfloor_delivery_shipment/__init__.py new file mode 100644 index 0000000000..e570ea3742 --- /dev/null +++ b/shopfloor_delivery_shipment/__init__.py @@ -0,0 +1,3 @@ +from . import actions +from . import models +from . import services diff --git a/shopfloor_delivery_shipment/__manifest__.py b/shopfloor_delivery_shipment/__manifest__.py new file mode 100644 index 0000000000..c57e3f1cff --- /dev/null +++ b/shopfloor_delivery_shipment/__manifest__.py @@ -0,0 +1,22 @@ +# 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": "13.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": True, + "depends": [ + # OCA/wms + "shopfloor", + # OCA/stock-logistics-transport + "shipment_advice", + ], + "data": ["data/shopfloor_scenario_data.xml", "views/shopfloor_menu.xml"], + "demo": ["demo/shopfloor_menu_demo.xml"], +} 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..30fc46c74f --- /dev/null +++ b/shopfloor_delivery_shipment/actions/data.py @@ -0,0 +1,59 @@ +# 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_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..d09f8958c0 --- /dev/null +++ b/shopfloor_delivery_shipment/actions/message.py @@ -0,0 +1,160 @@ +# 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 {} has not been planned in the shipment {}.").format( + picking.name, shipment_advice.name, + ), + } + + def package_not_planned_in_shipment(self, package, shipment_advice): + return { + "message_type": "error", + "body": _("Package {} has not been planned in the shipment {}.").format( + package.name, shipment_advice.name, + ), + } + + def lot_not_planned_in_shipment(self, lot, shipment_advice): + return { + "message_type": "error", + "body": _("Lot {} has not been planned in the shipment {}.").format( + lot.name, shipment_advice.name, + ), + } + + def product_not_planned_in_shipment(self, product, shipment_advice): + return { + "message_type": "error", + "body": _("Product {} has not been planned in the shipment {}.").format( + product.barcode, shipment_advice.name, + ), + } + + def unable_to_load_package_in_shipment(self, package, shipment_advice): + return { + "message_type": "error", + "body": _("Package {} can not been loaded in the shipment {}.").format( + package.name, shipment_advice.name, + ), + } + + def unable_to_load_lot_in_shipment(self, lot, shipment_advice): + return { + "message_type": "error", + "body": _("Lot {} can not been loaded in the shipment {}.").format( + lot.name, shipment_advice.name, + ), + } + + def unable_to_load_product_in_shipment(self, product, shipment_advice): + return { + "message_type": "error", + "body": _("Product {} can not been loaded in the shipment {}.").format( + product.barcode, shipment_advice.name, + ), + } + + def package_already_loaded_in_shipment(self, package, shipment_advice): + return { + "message_type": "warning", + "body": _("Package {} is already loaded in the shipment {}.").format( + package.name, shipment_advice.name, + ), + } + + def lot_already_loaded_in_shipment(self, lot, shipment_advice): + return { + "message_type": "warning", + "body": _("Lot {} is already loaded in the shipment {}.").format( + lot.name, shipment_advice.name, + ), + } + + def product_already_loaded_in_shipment(self, product, shipment_advice): + return { + "message_type": "warning", + "body": _("Product {} is already loaded in the shipment {}.").format( + product.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..f2ab99edba --- /dev/null +++ b/shopfloor_delivery_shipment/actions/schema.py @@ -0,0 +1,97 @@ +# 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_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_menu_demo.xml b/shopfloor_delivery_shipment/demo/shopfloor_menu_demo.xml new file mode 100644 index 0000000000..53e748accb --- /dev/null +++ b/shopfloor_delivery_shipment/demo/shopfloor_menu_demo.xml @@ -0,0 +1,14 @@ + + + Delivery with shipment + 55 + + + + 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..e1aff5adc1 --- /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=True)** + +== /scan_document == +scan_document -> scan_document: **/scan_document**(shipment_advice_id, barcode, picking_id=None) +scan_document -> loading_list: **/scan_document**(shipment_advice_id, barcode, picking_id=None) + +== /unload_move_line == +scan_document -> scan_document: **/unload_move_line**(shipment_advice_id, move_line_id) + +== /unload_package_level == +scan_document -> scan_document: **/unload_package_level**(shipment_advice_id, package_level_id) + +== /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 0000000000000000000000000000000000000000..f5e72d9ac2703b11191f3bef818794c9da64a555 GIT binary patch literal 71530 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 z%0D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 J{?+~2{{e(^M$!NP literal 0 HcmV?d00001 diff --git a/shopfloor_delivery_shipment/docs/oca_logo.png b/shopfloor_delivery_shipment/docs/oca_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..84f216c2941a7fd6c70bdb3c951539fd80258409 GIT binary patch literal 3297 zcmV<73?B1|P)%z00006VoOIv0RI60 z0RN!9r;`8x010qNS#tmY3ljhU3ljkVnw%H_000McNliruD}6aw7|dN=?803B&m zSad^gZEa<4bN~PV002XBWnpw>WFU8GbZ8()Nlj2>E@cM*01QP*L_t(|+U=Wra8%VD z$3JJ2@J2yuQ7fRfRVzAHE3LL_wGNt93y8LYxS`q#sJzZeZoZWe$rvF&bU!0CF-SAhHV z{oT|>>I+1GSAoZY2}yzt08Ywl5wJDsdjqfvr~;k_dUT7lYxH^=p}z_o3;ZqRdK`Eg z*aF0WHhujQJ=aCRb3m{EML{eKg%0~3tgZd8uVHeEz}vK-V?YzILw|17zcm2206l?8 zz((L>eeaoi&I>i5eT%ioa5zjj9PV`8ibT3JuW4DGuFWwF!r?G8YHHH1qbp807kUmj zFKPF`2|NRA{N{jRue`Ts>w#XSJ!{H*WuKjS^eNju*}r|*mOURW@7JTxbxp0!Ym(-< z1lR?XiD0saU~@HwU$Md{#Jh*Gri=Vt+bIviRA5rEj9qKsv_OyIK%!yMXd|&K4)b!er{>R?q;H(Q= zZ#0Vu!T~4|CB&ULT8mn;Ex@X)L(ZH@*fbGBlsS&mUkGs(a2aq8aAL|W?*h*P58yb@ zBSc(D*#-~_g)j`mThlCyN-ap!vJxPS0LB0p0B59V_7~cuZvfT++u9&jC8cAUCQ7NI z1JX|G$1X3~(Y$+*fLRH%B!s|moK6!vfI#5jput?=IL_Tdh=T#!a85dM2wV1kl#l5s z1冭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 literal 0 HcmV?d00001 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..04d0d79f74 --- /dev/null +++ b/shopfloor_delivery_shipment/services/delivery_shipment.py @@ -0,0 +1,859 @@ +# 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 + """ + 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=True, + ) + 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): + """Scan an operation, a package, a product or a lot. + + If an operation is scanned, reload the screen with the related planned + content or full content of this operation 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 + 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 + ) + search = self._actions_for("search") + scanned_picking = search.picking_from_scan(barcode) + if scanned_picking: + return self._scan_picking(shipment_advice, scanned_picking) + scanned_package = search.package_from_scan(barcode) + if scanned_package: + return self._scan_package(shipment_advice, scanned_package) + scanned_lot = search.lot_from_scan(barcode) + if scanned_lot: + return self._scan_lot(shipment_advice, scanned_lot) + scanned_product = search.product_from_scan(barcode) + if scanned_product: + return self._scan_product(shipment_advice, scanned_product, picking) + return self._response_for_scan_document( + shipment_advice, picking, message=self.msg_store.barcode_not_found() + ) + + 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): + """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) + 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 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, + 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, + ) + message = self.msg_store.unable_to_load_package_in_shipment( + package, shipment_advice + ) + if shipment_advice.planned_move_ids: + message = self.msg_store.package_not_planned_in_shipment( + package, shipment_advice + ) + return self._response_for_scan_document(shipment_advice, message=message) + + def _scan_lot(self, shipment_advice, lot): + """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) + 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 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 + ), + ) + # 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 + ), + ) + # 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, + ) + message = self.msg_store.unable_to_load_lot_in_shipment(lot, shipment_advice) + if shipment_advice.planned_move_ids: + message = self.msg_store.lot_not_planned_in_shipment(lot, shipment_advice) + return self._response_for_scan_document(shipment_advice, message=message) + + def _scan_product(self, shipment_advice, product, picking): + """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 + ) + 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 = self.msg_store.unable_to_load_product_in_shipment( + product, shipment_advice + ) + if shipment_advice.planned_move_ids: + message = self.msg_store.product_not_planned_in_shipment( + product, shipment_advice + ) + return self._response_for_scan_document(shipment_advice, message=message) + + def unload_move_line(self, shipment_advice_id, move_line_id): + """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() + ) + # Unload the move line + move_line._unload_from_shipment() + return self._response_for_scan_document(shipment_advice, move_line.picking_id) + + def unload_package_level(self, shipment_advice_id, package_level_id): + """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() + ) + # Unload the package level + package_level._unload_from_shipment() + return self._response_for_scan_document( + shipment_advice, package_level.picking_id + ) + + 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=False): + """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, message=None): + data = { + "shipment_advice": self.data.shipment_advice(shipment_advice), + } + if picking: + data.update( + picking=self.data.picking(picking), + content=self._data_for_content_to_load(shipment_advice, picking), + ) + 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 + ): + """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, 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(self, shipment_advice, picking): + """Return a tuple list of dictionaries 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": { + ... + }, + } + """ + data = collections.OrderedDict() + # Grab move lines to sort, restricted to the current delivery + move_lines = self._find_move_lines_to_process_from_picking( + shipment_advice, picking + ) + 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(self, dock): + return self.env["shipment.advice"].search( + [("dock_id", "=", dock.id), ("state", "in", ["in_progress"])], + limit=1, + order="arrival_date", + ) + + def _create_shipment_advice_from_dock(self, dock): + shipment_advice = self.env["shipment.advice"].create( + { + "dock_id": dock.id, + "arrival_date": fields.Datetime.to_string(fields.Datetime.now()), + } + ) + shipment_advice.action_confirm() + shipment_advice.action_in_progress() + return shipment_advice + + 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_lines).filtered( + lambda m: m.state in ("assigned", "partially_available") + ) + # Shipment without planned content + else: + # Restrict to delivery moves not planned + moves = picking.move_lines.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.""" + domain = [ + ("state", "in", ("assigned", "partially_available")), + ("picking_code", "=", "outgoing"), + ("picking_id.picking_type_id", "in", self.picking_types.ids), + "|", + ("shipment_advice_id", "=", False), + ("shipment_advice_id", "=", shipment_advice.id), + ] + # Shipment with planned content, restrict the search to it + if shipment_advice.planned_move_ids: + domain.append(("move_id.shipment_advice_id", "=", shipment_advice.id)) + # Shipment without planned content, search for all unplanned moves + else: + domain.append(("move_id.shipment_advice_id", "=", False)) + # Restrict to shipment carrier delivery types (providers) + if shipment_advice.carrier_ids: + domain.extend( + [ + "|", + ( + "picking_id.carrier_id.delivery_type", + "in", + shipment_advice.carrier_ids.mapped("delivery_type"), + ), + ("picking_id.carrier_id", "=", False), + ] + ) + return domain + + def _find_move_lines_from_package(self, shipment_advice, package): + """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)) + return self.env["stock.move.line"].search(domain) + + def _find_move_lines_from_lot(self, shipment_advice, lot): + """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)) + return self.env["stock.move.line"].search(domain) + + def _find_move_lines_from_product( + self, + shipment_advice, + product, + picking, + 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 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": { + "coerce": to_bool, + "required": False, + "nullable": True, + "type": "boolean", + }, + } + + 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", + }, + } + + 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"}, + } + + 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", + }, + } + + 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": "boolean", + "nullable": True, + "required": False, + }, + } + + @property + def _schema_scan_document(self): + shipment_schema = self.schemas.shipment_advice() + picking_schema = self.schemas.picking() + 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, + }, + } + + @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/index.html b/shopfloor_delivery_shipment/static/description/index.html new file mode 100644 index 0000000000..3028e883ca --- /dev/null +++ b/shopfloor_delivery_shipment/static/description/index.html @@ -0,0 +1,438 @@ + + + + + + +Shopfloor - Delivery with shipment advice + + + +
+

Shopfloor - Delivery with shipment advice

+ + +

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

+

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

+

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 smashing 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 maintainer:

+

sebalix

+

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

+

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

+
+
+
+ + diff --git a/shopfloor_delivery_shipment/tests/__init__.py b/shopfloor_delivery_shipment/tests/__init__.py new file mode 100644 index 0000000000..c683ac5e20 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/__init__.py @@ -0,0 +1,9 @@ +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_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..cd536c49b8 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_base.py @@ -0,0 +1,131 @@ +# 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): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref( + "shopfloor_delivery_shipment.shopfloor_menu_delivery_shipment" + ) + cls.profile = cls.env.ref("shopfloor_base.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" + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + 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_lines[:2] + lot_move = picking.move_lines[2] + raw_move = picking.move_lines[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() + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="delivery_shipment") + + @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=False + ): + 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, message=None + ): + data = { + "shipment_advice": self._data_for_shipment_advice(shipment_advice), + } + if picking: + data["picking"] = self.service.data.picking(picking) + data["content"] = self.service._data_for_content_to_load( + shipment_advice, picking + ) + 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..dab627de16 --- /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_lines) + # 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_lines) + # 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.normal_delivery_carrier") + (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.normal_delivery_carrier") + 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..32064ecab3 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_dock.py @@ -0,0 +1,60 @@ +# 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=True, + ) + # Second scan to confirm + response = self.service.dispatch( + "scan_dock", params={"barcode": self.dock.barcode, "confirmation": True} + ) + 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_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) 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..6108b6a124 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_lot.py @@ -0,0 +1,218 @@ +# 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_lines) + 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_lines(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_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_lines( + 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 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. + """ + 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_lines( + 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 + ), + ) 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..a332713394 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_package.py @@ -0,0 +1,175 @@ +# 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_lines) + 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_lines(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_lines(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..1be8a200d5 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_picking.py @@ -0,0 +1,258 @@ +# 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_lines(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_lines(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.normal_delivery_carrier") + 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..0a0ae373ad --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_scan_document_product.py @@ -0,0 +1,287 @@ +# 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_lines.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_lines.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, + 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_lines( + 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_lines( + 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, + 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_lines.filtered( + lambda m: m.product_id == scanned_product + ) + # Put two lots in stock + lot1 = self.env["stock.production.lot"].create( + {"product_id": scanned_product.id, "company_id": self.env.company.id} + ) + lot2 = self.env["stock.production.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..536ce5361e --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_unload.py @@ -0,0 +1,73 @@ +# 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_lines.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..70c2fcf751 --- /dev/null +++ b/shopfloor_delivery_shipment/tests/test_delivery_shipment_validate.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 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_lines) + 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_lines) + 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 + + + + + + + + + + + + From c789cd3656ff92539146946c2c484dfbc03d3a57 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Tue, 3 Aug 2021 07:56:18 +0000 Subject: [PATCH 02/35] [UPD] Update shopfloor_delivery_shipment.pot --- .../i18n/shopfloor_delivery_shipment.pot | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 shopfloor_delivery_shipment/i18n/shopfloor_delivery_shipment.pot 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..b1174dc056 --- /dev/null +++ b/shopfloor_delivery_shipment/i18n/shopfloor_delivery_shipment.pot @@ -0,0 +1,168 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_delivery_shipment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.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 +#: 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 "" From 99f20cd47135dc8946a15c5ee1a42ef671d1485c Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 3 Aug 2021 08:24:09 +0000 Subject: [PATCH 03/35] [UPD] README.rst --- shopfloor_delivery_shipment/README.rst | 12 ++++++++++-- .../static/description/index.html | 12 +++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/shopfloor_delivery_shipment/README.rst b/shopfloor_delivery_shipment/README.rst index 66808602fb..dd9d87c800 100644 --- a/shopfloor_delivery_shipment/README.rst +++ b/shopfloor_delivery_shipment/README.rst @@ -27,6 +27,11 @@ Shopfloor - Delivery with shipment advice 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:: @@ -85,10 +90,13 @@ 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 `maintainer `__: +Current `maintainers `__: -|maintainer-sebalix| +|maintainer-sebalix| |maintainer-TDu| This module is part of the `OCA/wms `_ project on GitHub. diff --git a/shopfloor_delivery_shipment/static/description/index.html b/shopfloor_delivery_shipment/static/description/index.html index 3028e883ca..991c04f5c4 100644 --- a/shopfloor_delivery_shipment/static/description/index.html +++ b/shopfloor_delivery_shipment/static/description/index.html @@ -3,7 +3,7 @@ - + Shopfloor - Delivery with shipment advice