From c30d1c9b9d424b3abe93e24f53035bc421745ff1 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 10 Jun 2020 15:39:00 +0200 Subject: [PATCH 01/21] location content transfer: add skeleton --- shopfloor/demo/shopfloor_menu_demo.xml | 9 + shopfloor/demo/stock_picking_type_demo.xml | 15 + shopfloor/models/shopfloor_menu.py | 1 + shopfloor/models/stock_picking.py | 6 + shopfloor/services/__init__.py | 1 + .../services/location_content_transfer.py | 439 ++++++++++++++++++ shopfloor/tests/__init__.py | 1 + .../test_location_content_transfer_base.py | 20 + 8 files changed, 492 insertions(+) create mode 100644 shopfloor/services/location_content_transfer.py create mode 100644 shopfloor/tests/test_location_content_transfer_base.py diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 0c2f671777..2880a74a80 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -36,4 +36,13 @@ eval="[(4, ref('shopfloor.picking_type_delivery_demo'))]" /> + + Location Content Transfer + 60 + location_content_transfer + + diff --git a/shopfloor/demo/stock_picking_type_demo.xml b/shopfloor/demo/stock_picking_type_demo.xml index c64cf5060d..2dbedd83ad 100644 --- a/shopfloor/demo/stock_picking_type_demo.xml +++ b/shopfloor/demo/stock_picking_type_demo.xml @@ -60,4 +60,19 @@ + + Location Content Transfer + LCT + + + + + + + + + internal + + + diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 362c96c58a..269d87aa01 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -37,6 +37,7 @@ def _selection_scenario(self): ("cluster_picking", "Cluster Picking"), ("checkout", "Checkout/Packing"), ("delivery", "Delivery"), + ("location_content_transfer", "Location Content Transfer"), ] @api.depends("scenario", "picking_type_ids") diff --git a/shopfloor/models/stock_picking.py b/shopfloor/models/stock_picking.py index d266f38cce..cdcd93cd08 100644 --- a/shopfloor/models/stock_picking.py +++ b/shopfloor/models/stock_picking.py @@ -30,3 +30,9 @@ def _calc_weight(self): for move_line in self.mapped("move_line_ids"): weight += move_line.product_qty * move_line.product_id.weight return weight + + def _create_backorder(self): + if self.env.context.get("_sf_no_backorder"): + return self.browse() + else: + return super()._create_backorder() diff --git a/shopfloor/services/__init__.py b/shopfloor/services/__init__.py index 85000d1508..09470e83ee 100644 --- a/shopfloor/services/__init__.py +++ b/shopfloor/services/__init__.py @@ -14,4 +14,5 @@ from . import checkout from . import cluster_picking from . import delivery +from . import location_content_transfer from . import single_pack_transfer diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py new file mode 100644 index 0000000000..d8e98a9366 --- /dev/null +++ b/shopfloor/services/location_content_transfer.py @@ -0,0 +1,439 @@ +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + +from .service import to_float + +# NOTE for the implementation: share several similarities with the "cluster +# picking" scenario + + +class LocationContentTransfer(Component): + """ + Methods for the Location Content Transfer Process + + Move the full content of a location to one or another location. + + Generally used to move a pallet with multiple boxes to either: + + * 1 destination location, unloading the full pallet + * To multiple destination locations, unloading one product/lot per + locations + * To multiple destination locations, unloading one product/lot per + locations and then unloading all remaining product/lot to a single final + destination + + The move lines must exist beforehand, the workflow only moves them. + + Expected: + + * All move lines and package level have a destination set, and are done. + + 2 complementary actions are possible on the screens allowing to move a line: + + * Declare a stock out for a product or package (nothing found in the + location) + * Skip to the next line (will be asked again at the end) + + Flow Diagram: https://www.draw.io/#G1qRenBcezk50ggIazDuu2qOfkTsoIAxXP + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.location.content.transfer" + _usage = "location_content_transfer" + _description = __doc__ + + def _response_for_start(self, message=None): + """Transition to the 'start' state""" + return self._response(next_state="start", message=message) + + def _response_for_scan_destination_all(self, location, message=None): + """Transition to the 'scan_destination_all' state + + The client screen shows a summary of all the lines and packages + to move to a single destination. + """ + return self._response( + next_state="scan_destination_all", + data=self._data_content_all_for_location(location), + message=message, + ) + + def _response_for_start_single( + self, location, package_level=None, line=None, message=None + ): + """Transition to the 'start_single' state + + The client screen shows details of the package level or move line to move. + """ + assert package_level or line + return self._response( + next_state="start_single", + data=self._data_content_line_for_location( + location, package_level=package_level, line=line + ), + message=message, + ) + + def _response_for_scan_destination( + self, location, package_level=None, line=None, message=None + ): + """Transition to the 'start_single' state + + The client screen shows details of the package level or move line to move. + """ + assert package_level or line + return self._response( + next_state="scan_destination", + data=self._data_content_line_for_location( + location, package_level=package_level, line=line + ), + message=message, + ) + + def _data_content_all_for_location(self, location): + return {} + + def _data_content_line_for_location(self, location, package_level=None, line=None): + assert package_level or line + return {} + + def start_or_recover(self): + """Start a new session or recover an existing one + + If the current user had transfers in progress in this scenario + and reopen the menu, we want to directly reopen the screens to choose + destinations. Otherwise, we go to the "start" state. + """ + # TODO if we find any stock.picking != done with current user as user id + # and with move lines having a qty_done > 0, in the current picking types, + # reach start_single or scan_destination_all + return self._response_for_start() + + def scan_location(self, barcode): + """Scan start location + + Called at the beginning at the workflow to select the location from which + we want to move the content. + + All the move lines and package levels must have the same picking type. + + When move lines and package levels have different destinations, the + first line without package level or package level is sent to the client. + + Transitions: + * start: location not found, ... + * scan_destination_all: if the destination of all the lines and package + levels have the same destination + * start_single: if any line or package level has a different destination + """ + return self._response() + + def set_destination_all(self, location_id, barcode): + """Scan destination location for all the moves of the location + + barcode is a stock.location for the destination + + Transitions: + * scan_destination_all: invalid destination or could not set moves to done + * start: moves are done + """ + return self._response() + + def go_to_single(self, location_id): + """Ask the first move line or package level + + If the user was brought to the screen allowing to move everything to + the same location, but they want to move them to different locations, + this method will return the first move line or package level. + + Transitions: + * start: no remaining lines in the location + * start_single: if any line or package level has a different destination + """ + return self._response() + + def scan_package(self, location_id, package_level_id, barcode): + """Scan a package level to move + + It validates that the user scanned the correct package, lot or product. + + Transitions: + * start: no remaining lines in the location + * start_single: barcode not found, ... + * scan_destination: the barcode matches + """ + return self._response() + + def scan_line(self, location_id, move_line_id, barcode): + """Scan a move line to move + + It validates that the user scanned the correct package, lot or product. + + Transitions: + * start: no remaining lines in the location + * start_single: barcode not found, ... + * scan_destination: the barcode matches + """ + return self._response() + + def set_destination_package(self, location_id, package_level_id, barcode): + """Scan destination location for package level + + If the move has other move lines / package levels it has to be split + so we can post only this part. + + After the destination is set, the move is set to done. + + Beware, when _action_done() is called on the move, the normal behavior + of Odoo would be to create a backorder transfer. We don't want this or + we would have a backorder per move. The context key + ``_sf_no_backorder`` disables the creation of backorders, it must be set + on all moves, but the last one of a transfer (so in case something was not + available, a backorder is created). + + Transitions: + * scan_destination: invalid destination or could not + * start_single: continue with the next package level / line + """ + return self._response() + + def set_destination_line(self, location_id, move_line_id, quantity, barcode): + """Scan destination location for move line + + If the quantity < qty of the line, split the move and reserve it. + If the move has other move lines / package levels it has to be split + so we can post only this part. + + After the destination and quantity are set, the move is set to done. + + Beware, when _action_done() is called on the move, the normal behavior + of Odoo would be to create a backorder transfer. We don't want this or + we would have a backorder per move. The context key + ``_sf_no_backorder`` disables the creation of backorders, it must be set + on all moves, but the last one of a transfer (so in case something was not + available, a backorder is created). + + Transitions: + * scan_destination: invalid destination or could not + * start_single: continue with the next package level / line + """ + return self._response() + + def postpone_package(self, location_id, package_level_id): + """Mark a package level as postponed and return the next level/line + + NOTE for implementation: Use the field "shopfloor_postponed", which has + to be included in the sort to get the next lines. + + Transitions: + * start_single: continue with the next package level / line + """ + return self._response() + + def postpone_line(self, location_id, move_line_id): + """Mark a move line as postponed and return the next level/line + + NOTE for implementation: Use the field "shopfloor_postponed", which has + to be included in the sort to get the next lines. + + Transitions: + * start_single: continue with the next package level / line + """ + return self._response() + + def stock_out_package(self, location_id, package_level_id): + """Declare a stock out on a package level + + It first ensures the stock.move only has this package level. If not, it + splits the move to have no side-effect on the other package levels/move + lines. + + It unreserves the move, create an inventory at 0 in the move's source + location, create a second draft inventory (if none exists) to check later. + Finally, it cancels the move. + + Transitions: + * start: no more content to move + * start_single: continue with the next package level / line + """ + return self._response() + + def stock_out_line(self, location_id, move_line_id): + """Declare a stock out on a move line + + It first ensures the stock.move only has this move line. If not, it + splits the move to have no side-effect on the other package levels/move + lines. + + It unreserves the move, create an inventory at 0 in the move's source + location, create a second draft inventory (if none exists) to check later. + Finally, it cancels the move. + + Transitions: + * start: no more content to move + * start_single: continue with the next package level / line + """ + return self._response() + + +class ShopfloorLocationContentTransferValidator(Component): + """Validators for the Location Content Transfer endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.location.content.transfer.validator" + _usage = "location_content_transfer.validator" + + def start_or_recover(self): + return {} + + def scan_location(self): + return {"barcode": {"required": True, "type": "string"}} + + def set_destination_all(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def go_to_single(self): + return {"location_id": {"coerce": to_int, "required": True, "type": "integer"}} + + def scan_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def scan_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def set_destination_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def set_destination_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "quantity": {"coerce": to_float, "required": True, "type": "float"}, + "barcode": {"required": True, "type": "string"}, + } + + def postpone_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def postpone_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def stock_out_package(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + def stock_out_line(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + +class ShopfloorLocationContentTransferValidatorResponse(Component): + """Validators for the Location Content Transfer endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.location.content.transfer.validator.response" + _usage = "location_content_transfer.validator.response" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "start": {}, + "scan_destination_all": self._schema_all, + "start_single": self._schema_single, + "scan_destination": self._schema_single, + } + + @property + def _schema_all(self): + package_schema = self.schemas.package() + move_line_schema = self.schemas.move_line() + return { + # we'll display all the packages and move lines *without package + # levels* + "packages": self.schemas._schema_list_of(package_schema), + "move_lines": self.schemas._schema_list_of(move_line_schema), + } + + @property + def _schema_single(self): + schema_package_level = self.schemas.package_level() + schema_move_line = self.schemas.move_line() + return { + # we'll have one or the other... + # TODO add the package in the package_level + "package_level": self.schemas._schema_dict_of(schema_package_level), + "move_line": self.schemas._schema_dict_of(schema_move_line), + } + + def start_or_recover(self): + return self._response_schema( + next_states={"start", "scan_destination_all", "start_single"} + ) + + def scan_location(self): + return self._response_schema( + next_states={"start", "scan_destination_all", "start_single"} + ) + + def set_destination_all(self): + return self._response_schema(next_states={"start", "scan_destination_all"}) + + def go_to_single(self): + return self._response_schema(next_states={"start", "start_single"}) + + def scan_package(self): + return self._response_schema( + next_states={"start", "start_single", "scan_destination"} + ) + + def scan_line(self): + return self._response_schema( + next_states={"start", "start_single", "scan_destination"} + ) + + def set_destination_package(self): + return self._response_schema(next_states={"start_single", "scan_destination"}) + + def set_destination_line(self): + return self._response_schema(next_states={"start_single", "scan_destination"}) + + def postpone_package(self): + return self._response_schema(next_states={"start_single"}) + + def postpone_line(self): + return self._response_schema(next_states={"start_single"}) + + def stock_out_package(self): + return self._response_schema(next_states={"start", "start_single"}) + + def stock_out_line(self): + return self._response_schema(next_states={"start", "start_single"}) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 5b470c54df..fb9f55a585 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -33,3 +33,4 @@ from . import test_delivery_set_qty_done_line from . import test_delivery_list_stock_picking from . import test_delivery_select +from . import test_location_content_transfer_base diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py new file mode 100644 index 0000000000..f66e8be98f --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -0,0 +1,20 @@ +from .common import CommonCase + + +class LocationContentTransferCommonCase(CommonCase): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor.shopfloor_menu_location_content_transfer") + cls.profile = cls.env.ref("shopfloor.shopfloor_profile_shelf_1_demo") + cls.wh = cls.profile.warehouse_id + cls.picking_type = cls.menu.picking_type_ids + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + + def setUp(self): + super().setUp() + with self.work_on_services(menu=self.menu, profile=self.profile) as work: + self.service = work.component(usage="location_content_transfer") From e35631d801aec49eb5e6dc1b2d03bb9f0a177980 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 17 Jun 2020 14:49:08 +0200 Subject: [PATCH 02/21] location transfer: implement /start_or_recover, /scan_location --- shopfloor/actions/__init__.py | 1 + shopfloor/actions/data.py | 25 +++ .../location_content_transfer_sorter.py | 56 ++++++ shopfloor/actions/message.py | 14 ++ .../services/location_content_transfer.py | 142 +++++++++++--- shopfloor/services/schema.py | 5 +- shopfloor/services/single_pack_transfer.py | 11 +- shopfloor/tests/__init__.py | 1 + shopfloor/tests/common.py | 6 +- .../test_location_content_transfer_base.py | 56 ++++++ .../test_location_content_transfer_start.py | 174 ++++++++++++++++++ 11 files changed, 458 insertions(+), 33 deletions(-) create mode 100644 shopfloor/actions/location_content_transfer_sorter.py create mode 100644 shopfloor/tests/test_location_content_transfer_start.py diff --git a/shopfloor/actions/__init__.py b/shopfloor/actions/__init__.py index ac8c5da74d..42f45d7eee 100644 --- a/shopfloor/actions/__init__.py +++ b/shopfloor/actions/__init__.py @@ -20,6 +20,7 @@ from . import data from . import data_detail from . import completion_info +from . import location_content_transfer_sorter from . import message from . import search from . import inventory diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index 326e19ff91..c2efca8ba6 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -148,6 +148,31 @@ def _move_line_parser(self): ("location_dest_id:location_dest", self._location_parser), ] + def package_level(self, record, **kw): + data = self._jsonify(record, self._package_level_parser) + if data: + data.update( + { + # cannot use sub-parser here + # because location_id of the package level may be + # empty, we have to go get the picking's one + "location_src": self.location(record.picking_id.location_id, **kw), + } + ) + return data + + def package_levels(self, records, **kw): + return [self.package_level(rec, **kw) for rec in records] + + @property + def _package_level_parser(self): + return [ + "id", + "is_done", + ("package_id:package", self._package_parser), + ("location_dest_id:location_dest", self._location_parser), + ] + def product(self, record, **kw): return self._jsonify(record, self._product_parser, **kw) diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py new file mode 100644 index 0000000000..6777636dca --- /dev/null +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -0,0 +1,56 @@ +from odoo.addons.component.core import Component + + +class LocationContentTransferSorter(Component): + + _name = "shopfloor.location.content.transfer.sorter" + _inherit = "shopfloor.process.action" + _usage = "location_content_transfer.sorter" + + def __init__(self, work_context): + super().__init__(work_context) + self._pickings = self.env["stock.picking"].browse() + self._content = None + + def feed_pickings(self, pickings): + self._pickings |= pickings + + def move_lines(self): + return self._pickings.move_line_ids.filtered( + # lines without package level only (raw products) + lambda line: not line.package_level_id + and line.state not in ("cancel", "done") + ) + + def package_levels(self): + return self._pickings.package_level_ids.filtered( + lambda level: level.state not in ("cancel", "done") + ) + + @staticmethod + def _sort_key(content): + # content can be either a move line, either a package + # level + return ( + # sort by similar destination + content.location_dest_id.complete_name, + # lines before packages (if we have raw products and packages, raw + # will be on top? wild guess) + 0 if content._name == "stock.move.line" else 1, + # to have a deterministic sort + content.id, + ) + + def sort(self): + content = [line for line in self.move_lines()] + [ + level for level in self.package_levels() + ] + self._content = sorted(content, key=self._sort_key) + + def __iter__(self): + if self._content is None: + self.sort() + return iter(self._content) + + def __next__(self): + return next(iter(self)) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 143bb83e7b..db160ee3f4 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -299,6 +299,14 @@ def transfer_complete(self, picking): "body": _("Transfer {} complete").format(picking.name), } + def location_content_transfer_complete(self, location): + return { + "message_type": "success", + "body": _("Location Content Transfer from {} complete").format( + location.name + ), + } + def transfer_confirm_done(self): return { "message_type": "warning", @@ -315,3 +323,9 @@ def transfer_no_qty_done(self): "No quantity has been processed, unable to complete the transfer." ), } + + def recovered_previous_session(self): + return { + "message_type": "info", + "body": _("Recovered previous session."), + } diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index d8e98a9366..9effb63b7e 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -1,3 +1,5 @@ +from odoo import _ + from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -46,7 +48,7 @@ def _response_for_start(self, message=None): """Transition to the 'start' state""" return self._response(next_state="start", message=message) - def _response_for_scan_destination_all(self, location, message=None): + def _response_for_scan_destination_all(self, location, pickings=None, message=None): """Transition to the 'scan_destination_all' state The client screen shows a summary of all the lines and packages @@ -54,48 +56,94 @@ def _response_for_scan_destination_all(self, location, message=None): """ return self._response( next_state="scan_destination_all", - data=self._data_content_all_for_location(location), + data=self._data_content_all_for_location(location, pickings=pickings), message=message, ) - def _response_for_start_single( - self, location, package_level=None, line=None, message=None - ): + def _response_for_start_single(self, location, next_content, message=None): """Transition to the 'start_single' state The client screen shows details of the package level or move line to move. """ - assert package_level or line return self._response( next_state="start_single", - data=self._data_content_line_for_location( - location, package_level=package_level, line=line - ), + data=self._data_content_line_for_location(location, next_content), message=message, ) - def _response_for_scan_destination( - self, location, package_level=None, line=None, message=None - ): + def _response_for_scan_destination(self, location, next_content, message=None): """Transition to the 'start_single' state The client screen shows details of the package level or move line to move. """ - assert package_level or line return self._response( next_state="scan_destination", - data=self._data_content_line_for_location( - location, package_level=package_level, line=line - ), + data=self._data_content_line_for_location(location, next_content), message=message, ) - def _data_content_all_for_location(self, location): - return {} + def _data_content_all_for_location(self, location, pickings=None): + if not pickings: + # TODO get pickings from location + raise NotImplementedError("to do: get pickings from location") + sorter = self.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + lines = sorter.move_lines() + package_levels = sorter.package_levels() + return { + "move_lines": self.data.move_lines(lines), + "package_levels": self.data.package_levels(package_levels), + } - def _data_content_line_for_location(self, location, package_level=None, line=None): - assert package_level or line - return {} + def _data_content_line_for_location(self, location, next_content): + assert next_content._name in ("stock.move.line", "stock.package_level") + line_data = ( + self.data.move_line(next_content) + if next_content._name == "stock.move.line" + else None + ) + level_data = ( + self.data.package_level(next_content) + if next_content._name == "stock.package_level" + else None + ) + return {"move_line": line_data, "package_level": level_data} + + def _router_single_or_all_destination(self, pickings, message=None): + location = pickings.mapped("location_id") + if len(pickings.mapped("move_line_ids.location_dest_id")) == 1: + return self._response_for_scan_destination_all( + location, pickings=pickings, message=message + ) + else: + sorter = self.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + try: + next_content = next(sorter) + except StopIteration: + # TODO test + return self._response_for_start( + message=self.msg_store.location_content_transfer_complete(location) + ) + return self._response_for_start_single( + location, next_content, message=message + ) + + def _domain_recover_pickings(self): + return [ + ("user_id", "=", self.env.uid), + ("state", "in", ("assigned", "partially_available")), + ("picking_type_id", "in", self.picking_types.ids), + ] + + def _search_recover_pickings(self): + candidate_pickings = self.env["stock.picking"].search( + self._domain_recover_pickings() + ) + started_pickings = candidate_pickings.filtered( + lambda picking: any(line.qty_done for line in picking.move_line_ids) + ) + return started_pickings def start_or_recover(self): """Start a new session or recover an existing one @@ -104,11 +152,24 @@ def start_or_recover(self): and reopen the menu, we want to directly reopen the screens to choose destinations. Otherwise, we go to the "start" state. """ - # TODO if we find any stock.picking != done with current user as user id - # and with move lines having a qty_done > 0, in the current picking types, - # reach start_single or scan_destination_all + started_pickings = self._search_recover_pickings() + if started_pickings: + return self._router_single_or_all_destination( + started_pickings, message=self.msg_store.recovered_previous_session() + ) return self._response_for_start() + def _find_location_move_lines_domain(self, location): + return [ + ("location_id", "=", location.id), + ("qty_done", "=", 0), + ] + + def _find_location_move_lines(self, location): + return self.env["stock.move.line"].search( + self._find_location_move_lines_domain(location) + ) + def scan_location(self, barcode): """Scan start location @@ -126,7 +187,34 @@ def scan_location(self, barcode): levels have the same destination * start_single: if any line or package level has a different destination """ - return self._response() + location = self.actions_for("search").location_from_scan(barcode) + if not location: + return self._response_for_start(message=self.msg_store.barcode_not_found()) + move_lines = self._find_location_move_lines(location) + pickings = move_lines.mapped("picking_id") + picking_types = pickings.mapped("picking_type_id") + + if len(picking_types) > 1: + return self._response_for_start( + message={ + "message_type": "error", + "body": _("This location content can't be moved at once."), + } + ) + if picking_types - self.picking_types: + return self._response_for_start( + message={ + "message_type": "error", + "body": _("This location content can't be moved using this menu."), + } + ) + + for line in move_lines: + line.qty_done = line.product_uom_qty + + pickings.user_id = self.env.uid + + return self._router_single_or_all_destination(pickings) def set_destination_all(self, location_id, barcode): """Scan destination location for all the moves of the location @@ -374,12 +462,12 @@ def _states(self): @property def _schema_all(self): - package_schema = self.schemas.package() + package_level_schema = self.schemas.package_level() move_line_schema = self.schemas.move_line() return { # we'll display all the packages and move lines *without package # levels* - "packages": self.schemas._schema_list_of(package_schema), + "package_levels": self.schemas._schema_list_of(package_level_schema), "move_lines": self.schemas._schema_list_of(move_line_schema), } diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index f894f82250..c019d057e5 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -170,9 +170,8 @@ def picking_batch(self, with_pickings=False): def package_level(self): return { "id": {"required": True, "type": "integer"}, - "name": {"type": "string", "nullable": False, "required": True}, + "is_done": {"type": "boolean", "nullable": False, "required": True}, + "package": {"type": "dict", "schema": self.package()}, "location_src": {"type": "dict", "schema": self.location()}, "location_dest": {"type": "dict", "schema": self.location()}, - "product": {"type": "dict", "schema": self.product()}, - "picking": {"type": "dict", "schema": self.picking()}, } diff --git a/shopfloor/services/single_pack_transfer.py b/shopfloor/services/single_pack_transfer.py index 085f851a56..2306432b18 100644 --- a/shopfloor/services/single_pack_transfer.py +++ b/shopfloor/services/single_pack_transfer.py @@ -13,6 +13,7 @@ class SinglePackTransfer(Component): def _data_after_package_scanned(self, package_level): move_line = package_level.move_line_ids[0] package = package_level.package_id + # TODO use data.package_level (but the "name" moves in "package.name") return { "id": package_level.id, "name": package.name, @@ -288,4 +289,12 @@ def validate(self): @property def _schema_for_package_level_details(self): - return self.schemas.package_level() + # TODO use schemas.package_level (but the "name" moves in "package.name") + return { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "location_src": {"type": "dict", "schema": self.schemas.location()}, + "location_dest": {"type": "dict", "schema": self.schemas.location()}, + "product": {"type": "dict", "schema": self.schemas.product()}, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index fb9f55a585..83efe92bcf 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -34,3 +34,4 @@ from . import test_delivery_list_stock_picking from . import test_delivery_select from . import test_location_content_transfer_base +from . import test_location_content_transfer_start diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 1f83cde1eb..9fbd131bec 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -288,13 +288,15 @@ def _update_qty_in_location( ) @classmethod - def _fill_stock_for_moves(cls, moves, in_package=False, in_lot=False): + def _fill_stock_for_moves( + cls, moves, in_package=False, in_lot=False, location=False + ): product_locations = {} package = None if in_package: package = cls.env["stock.quant.package"].create({}) for move in moves: - key = (move.product_id, move.location_id) + key = (move.product_id, location or move.location_id) product_locations.setdefault(key, 0) product_locations[key] += move.product_qty for (product, location), qty in product_locations.items(): diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index f66e8be98f..c5ebe0569c 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -13,8 +13,64 @@ def setUpClassVars(cls, *args, **kwargs): @classmethod def setUpClassBaseData(cls, *args, **kwargs): super().setUpClassBaseData(*args, **kwargs) + cls.content_loc = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Content Location", + "barcode": "Content", + "location_id": cls.picking_type.default_location_src_id.id, + } + ) + ) def setUp(self): super().setUp() with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="location_content_transfer") + + def _simulate_pickings_selected(self, pickings): + """Create a state as if pickings has been selected + + ... during a Location content transfer. + + It means a user scanned the location with the pickings. They are: + + * assigned to the user + * the qty_done of all their move lines is set to they reserved qty + + """ + pickings.user_id = self.env.uid + for line in pickings.mapped("move_line_ids"): + line.qty_done = line.product_uom_qty + + def assert_response_start(self, response, message=None): + self.assert_response(response, next_state="start", message=message) + + def assert_response_scan_destination_all(self, response, pickings, message=None): + # this code is repeated from the implementation, not great, but we + # mostly want to ensure the selection of pickings is right, and the + # data methods have their own tests + lines = pickings.move_line_ids.filtered(lambda line: not line.package_level_id) + package_levels = pickings.package_level_ids + self.assert_response( + response, + next_state="scan_destination_all", + data={ + "move_lines": self.data.move_lines(lines), + "package_levels": self.data.package_levels(package_levels), + }, + message=message, + ) + + def assert_response_start_single(self, response, pickings, message=None): + sorter = self.service.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + location = pickings.mapped("location_id") + self.assert_response( + response, + next_state="start_single", + data=self.service._data_content_line_for_location(location, next(sorter)), + message=message, + ) diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py new file mode 100644 index 0000000000..378048a7bc --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -0,0 +1,174 @@ +from .test_location_content_transfer_base import LocationContentTransferCommonCase + + +class LocationContentTransferStartCase(LocationContentTransferCommonCase): + """Tests for start state and recover + + Endpoints: + + * /start_or_recover + * /scan_location + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_lines, in_package=True, location=cls.content_loc + ) + cls._fill_stock_for_moves(picking2.move_lines, location=cls.content_loc) + cls.pickings.action_assign() + + def test_start_fresh(self): + """Start a fresh session when there is no transfer to recover""" + response = self.service.dispatch("start_or_recover", params={}) + self.assert_response(response, next_state="start") + + def test_start_recover_destination_all(self): + """Recover transfers, all move lines have the same destination""" + self._simulate_pickings_selected(self.pickings) + # all lines go to the same destination (shelf1) + self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 1) + + response = self.service.dispatch("start_or_recover", params={}) + self.assert_response_scan_destination_all( + response, + self.pickings, + message=self.service.msg_store.recovered_previous_session(), + ) + + def test_start_recover_destination_single(self): + """Recover transfers, at least one move line has a different destination""" + self._simulate_pickings_selected(self.pickings) + self.picking1.package_level_ids.location_dest_id = self.shelf2 + # we have different destinations + self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 2) + response = self.service.dispatch("start_or_recover", params={}) + self.assert_response_start_single( + response, + self.pickings, + message=self.service.msg_store.recovered_previous_session(), + ) + + def test_scan_location_not_found(self): + """Scan a location with content to transfer, barcode not found""" + response = self.service.dispatch( + "scan_location", params={"barcode": "NOT_FOUND"} + ) + self.assert_response_start( + response, message=self.service.msg_store.barcode_not_found() + ) + + def test_scan_location_find_content_destination_all(self): + """Scan a location with content to transfer, all dest. identical""" + # all lines go to the same destination (shelf1) + self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 1) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_scan_destination_all(response, self.pickings) + self.assertRecordValues( + self.pickings, [{"user_id": self.env.uid}, {"user_id": self.env.uid}] + ) + self.assertRecordValues( + self.pickings.move_line_ids, + [ + {"qty_done": 10.0}, + {"qty_done": 10.0}, + {"qty_done": 10.0}, + {"qty_done": 10.0}, + ], + ) + self.assertRecordValues(self.picking1.package_level_ids, [{"is_done": True}]) + + def test_scan_location_find_content_destination_single(self): + """Scan a location with content to transfer, different destinations""" + self.picking1.package_level_ids.location_dest_id = self.shelf2 + # we have different destinations + self.assertEqual(len(self.pickings.mapped("move_line_ids.location_dest_id")), 2) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start_single(response, self.pickings) + self.assertRecordValues( + self.pickings, [{"user_id": self.env.uid}, {"user_id": self.env.uid}] + ) + self.assertRecordValues( + self.pickings.move_line_ids, + [ + {"qty_done": 10.0}, + {"qty_done": 10.0}, + {"qty_done": 10.0}, + {"qty_done": 10.0}, + ], + ) + self.assertRecordValues(self.picking1.package_level_ids, [{"is_done": True}]) + + def test_scan_location_different_picking_type(self): + """Content has different picking types, can't move""" + picking_other_type = self._create_picking( + picking_type=self.wh.pick_type_id, lines=[(self.product_a, 10)] + ) + self._fill_stock_for_moves( + picking_other_type.move_lines, location=self.content_loc + ) + picking_other_type.action_assign() + + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start( + response, + message={ + "message_type": "error", + "body": "This location content can't be moved at once.", + }, + ) + + +class LocationContentTransferStartSpecialCase(LocationContentTransferCommonCase): + """Tests for start state and recover (special cases without setup) + + Endpoints: + + * /start_or_recover + * /scan_location + """ + + def test_scan_location_wrong_picking_type(self): + """Content has different picking type than menu""" + picking = self._create_picking( + picking_type=self.wh.pick_type_id, + lines=[(self.product_a, 10), (self.product_b, 10)], + ) + self._fill_stock_for_moves( + picking.move_lines, in_package=True, location=self.content_loc + ) + picking.action_assign() + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response_start( + response, + message={ + "message_type": "error", + "body": "This location content can't be moved using this menu.", + }, + ) From 816a8ff2bd39df2cd7702071bd3e71cf2a8496e5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 08:09:38 +0200 Subject: [PATCH 03/21] checkout: fix state change when picking not found --- shopfloor/services/checkout.py | 58 +++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index d73ee56489..3f08861d8a 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -308,7 +308,9 @@ def scan_line(self, picking_id, barcode): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) search = self.actions_for("search") @@ -457,7 +459,9 @@ def select_line(self, picking_id, package_id=None, move_line_id=None): picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) selection_lines = self._lines_to_pack(picking) if not selection_lines: @@ -475,7 +479,9 @@ def _change_line_qty( ): picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) move_lines = self.env["stock.move.line"].browse(move_line_ids).exists() @@ -669,7 +675,9 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) search = self.actions_for("search") selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() @@ -718,7 +726,9 @@ def new_package(self, picking_id, selected_line_ids): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._create_and_assign_new_packaging(picking, selected_lines) @@ -734,7 +744,9 @@ def no_package(self, picking_id, selected_line_ids): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists() selected_lines.write( {"shopfloor_checkout_done": True, "result_package_id": False} @@ -759,7 +771,9 @@ def list_dest_package(self, picking_id, selected_line_ids): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) lines = self.env["stock.move.line"].browse(selected_line_ids).exists() return self._response_for_select_dest_package(picking, lines) @@ -797,7 +811,9 @@ def scan_dest_package(self, picking_id, selected_line_ids, barcode): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) lines = self.env["stock.move.line"].browse(selected_line_ids).exists() search = self.actions_for("search") package = search.package_from_scan(barcode) @@ -817,7 +833,9 @@ def set_dest_package(self, picking_id, selected_line_ids, package_id): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) lines = self.env["stock.move.line"].browse(selected_line_ids).exists() package = self.env["stock.quant.package"].browse(package_id).exists() return self._set_dest_package_from_selection(picking, lines, package) @@ -830,7 +848,9 @@ def summary(self, picking_id): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) return self._response_for_summary(picking) def _get_allowed_packaging(self): @@ -848,7 +868,9 @@ def list_packaging(self, picking_id, package_id): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) package = self.env["stock.quant.package"].browse(package_id).exists() packaging_list = self._get_allowed_packaging() @@ -863,7 +885,9 @@ def set_packaging(self, picking_id, package_id, packaging_id): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) package = self.env["stock.quant.package"].browse(package_id).exists() packaging = self.env["product.packaging"].browse(packaging_id).exists() @@ -898,7 +922,9 @@ def cancel_line(self, picking_id, package_id=None, line_id=None): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) package = self.env["stock.quant.package"].browse(package_id).exists() line = self.env["stock.move.line"].browse(line_id).exists() @@ -941,7 +967,9 @@ def done(self, picking_id, confirmation=False): """ picking = self.env["stock.picking"].browse(picking_id) if not picking.exists(): - return self._response_stock_picking_does_not_exist() + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_found() + ) lines = picking.move_line_ids if not confirmation: if not all(line.qty_done == line.product_uom_qty for line in lines): @@ -1167,7 +1195,7 @@ def _schema_stock_picking(self, lines_with_packaging=False): { "move_lines": self.schemas._schema_list_of( self.schemas.move_line(with_packaging=lines_with_packaging) - ), + ) } ) return {"picking": self.schemas._schema_dict_of(schema, required=True)} From 91f6b188c8930c9d2bc52ff99cc4acf751c63369 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 10:53:14 +0200 Subject: [PATCH 04/21] location transfer: implement /set_destination_all --- .../location_content_transfer_sorter.py | 1 + .../services/location_content_transfer.py | 149 +++++++++++++++--- shopfloor/tests/__init__.py | 1 + .../test_location_content_transfer_base.py | 23 ++- ...on_content_transfer_set_destination_all.py | 142 +++++++++++++++++ 5 files changed, 292 insertions(+), 24 deletions(-) create mode 100644 shopfloor/tests/test_location_content_transfer_set_destination_all.py diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index 6777636dca..a79bc69242 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -32,6 +32,7 @@ def _sort_key(content): # content can be either a move line, either a package # level return ( + # TODO add postponed (need to be added to package_level) # sort by similar destination content.location_dest_id.complete_name, # lines before packages (if we have raw products and packages, raw diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 9effb63b7e..51bc7ea5bc 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -9,6 +9,9 @@ # picking" scenario +# TODO add picking and package content in package level? + + class LocationContentTransfer(Component): """ Methods for the Location Content Transfer Process @@ -48,7 +51,7 @@ def _response_for_start(self, message=None): """Transition to the 'start' state""" return self._response(next_state="start", message=message) - def _response_for_scan_destination_all(self, location, pickings=None, message=None): + def _response_for_scan_destination_all(self, pickings, message=None): """Transition to the 'scan_destination_all' state The client screen shows a summary of all the lines and packages @@ -56,7 +59,20 @@ def _response_for_scan_destination_all(self, location, pickings=None, message=No """ return self._response( next_state="scan_destination_all", - data=self._data_content_all_for_location(location, pickings=pickings), + data=self._data_content_all_for_location(pickings=pickings), + message=message, + ) + + def _response_for_confirm_scan_destination_all(self, pickings, message=None): + """Transition to the 'confirm_scan_destination_all' state + + The client screen shows a summary of all the lines and packages + to move to a single destination. The user has to scan the destination + location a second time to validate the destination. + """ + return self._response( + next_state="confirm_scan_destination_all", + data=self._data_content_all_for_location(pickings=pickings), message=message, ) @@ -72,7 +88,7 @@ def _response_for_start_single(self, location, next_content, message=None): ) def _response_for_scan_destination(self, location, next_content, message=None): - """Transition to the 'start_single' state + """Transition to the 'scan_destination' state The client screen shows details of the package level or move line to move. """ @@ -82,7 +98,22 @@ def _response_for_scan_destination(self, location, next_content, message=None): message=message, ) - def _data_content_all_for_location(self, location, pickings=None): + def _response_for_confirm_scan_destination( + self, location, next_content, message=None + ): + """Transition to the 'confirm_scan_destination' state + + The client screen shows details of the package level or move line to + move. The user has to scan the destination location a second time to + validate the destination. + """ + return self._response( + next_state="confirm_scan_destination", + data=self._data_content_line_for_location(location, next_content), + message=message, + ) + + def _data_content_all_for_location(self, pickings): if not pickings: # TODO get pickings from location raise NotImplementedError("to do: get pickings from location") @@ -109,19 +140,24 @@ def _data_content_line_for_location(self, location, next_content): ) return {"move_line": line_data, "package_level": level_data} + def _next_content(self, pickings): + sorter = self.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + try: + next_content = next(sorter) + except StopIteration: + # TODO set picking to done + return None + return next_content + def _router_single_or_all_destination(self, pickings, message=None): location = pickings.mapped("location_id") if len(pickings.mapped("move_line_ids.location_dest_id")) == 1: - return self._response_for_scan_destination_all( - location, pickings=pickings, message=message - ) + return self._response_for_scan_destination_all(pickings, message=message) else: - sorter = self.actions_for("location_content_transfer.sorter") - sorter.feed_pickings(pickings) - try: - next_content = next(sorter) - except StopIteration: - # TODO test + next_content = self._next_content(pickings) + if not next_content: + # TODO test (no more lines) return self._response_for_start( message=self.msg_store.location_content_transfer_complete(location) ) @@ -163,9 +199,11 @@ def _find_location_move_lines_domain(self, location): return [ ("location_id", "=", location.id), ("qty_done", "=", 0), + ("state", "in", ("assigned", "partially_available")), ] def _find_location_move_lines(self, location): + """Find lines that potentially are to move in the location""" return self.env["stock.move.line"].search( self._find_location_move_lines_domain(location) ) @@ -216,7 +254,28 @@ def scan_location(self, barcode): return self._router_single_or_all_destination(pickings) - def set_destination_all(self, location_id, barcode): + def _find_transfer_move_lines_domain(self, location): + return [ + ("location_id", "=", location.id), + ("state", "in", ("assigned", "partially_available")), + ("qty_done", ">", 0), + # TODO check generated SQL + ("picking_id.user_id", "=", self.env.uid), + ] + + def _find_transfer_move_lines(self, location): + """Find move lines currently being moved by the user""" + lines = self.env["stock.move.line"].search( + self._find_transfer_move_lines_domain(location) + ) + return lines + + def _set_destination_lines(self, pickings, move_lines, dest_location): + move_lines.location_dest_id = dest_location + move_lines.package_level_id.location_dest_id = dest_location + pickings.action_done() + + def set_destination_all(self, location_id, barcode, confirmation=False): """Scan destination location for all the moves of the location barcode is a stock.location for the destination @@ -225,7 +284,38 @@ def set_destination_all(self, location_id, barcode): * scan_destination_all: invalid destination or could not set moves to done * start: moves are done """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_lines = self._find_transfer_move_lines(location) + pickings = move_lines.mapped("picking_id") + scanned_location = self.actions_for("search").location_from_scan(barcode) + if not scanned_location: + return self._response_for_scan_destination_all( + pickings, message=self.msg_store.barcode_not_found() + ) + + if not scanned_location.is_sublocation_of( + self.picking_types.mapped("default_location_dest_id") + ): + return self._response_for_scan_destination_all( + pickings, message=self.msg_store.dest_location_not_allowed() + ) + if not confirmation and not scanned_location.is_sublocation_of( + move_lines.mapped("location_dest_id") + ): + # the scanned location is valid (child of picking type's destination) + # but not the expected one: ask for confirmation + return self._response_for_confirm_scan_destination_all(pickings) + + self._set_destination_lines(pickings, move_lines, scanned_location) + + return self._response_for_start( + message={ + "message_type": "success", + "body": _("Content transferred from {}.").format(location.name), + } + ) def go_to_single(self, location_id): """Ask the first move line or package level @@ -264,7 +354,9 @@ def scan_line(self, location_id, move_line_id, barcode): """ return self._response() - def set_destination_package(self, location_id, package_level_id, barcode): + def set_destination_package( + self, location_id, package_level_id, barcode, confirmation=False + ): """Scan destination location for package level If the move has other move lines / package levels it has to be split @@ -285,7 +377,9 @@ def set_destination_package(self, location_id, package_level_id, barcode): """ return self._response() - def set_destination_line(self, location_id, move_line_id, quantity, barcode): + def set_destination_line( + self, location_id, move_line_id, quantity, barcode, confirmation=False + ): """Scan destination location for move line If the quantity < qty of the line, split the move and reserve it. @@ -381,6 +475,7 @@ def set_destination_all(self): return { "location_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, } def go_to_single(self): @@ -405,6 +500,7 @@ def set_destination_package(self): "location_id": {"coerce": to_int, "required": True, "type": "integer"}, "package_level_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, } def set_destination_line(self): @@ -413,6 +509,7 @@ def set_destination_line(self): "move_line_id": {"coerce": to_int, "required": True, "type": "integer"}, "quantity": {"coerce": to_float, "required": True, "type": "float"}, "barcode": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, } def postpone_package(self): @@ -456,8 +553,10 @@ def _states(self): return { "start": {}, "scan_destination_all": self._schema_all, + "confirm_scan_destination_all": self._schema_all, "start_single": self._schema_single, "scan_destination": self._schema_single, + "confirm_scan_destination": self._schema_single, } @property @@ -493,7 +592,13 @@ def scan_location(self): ) def set_destination_all(self): - return self._response_schema(next_states={"start", "scan_destination_all"}) + return self._response_schema( + next_states={ + "start", + "scan_destination_all", + "confirm_scan_destination_all", + } + ) def go_to_single(self): return self._response_schema(next_states={"start", "start_single"}) @@ -509,10 +614,14 @@ def scan_line(self): ) def set_destination_package(self): - return self._response_schema(next_states={"start_single", "scan_destination"}) + return self._response_schema( + next_states={"start_single", "scan_destination", "confirm_scan_destination"} + ) def set_destination_line(self): - return self._response_schema(next_states={"start_single", "scan_destination"}) + return self._response_schema( + next_states={"start_single", "scan_destination", "confirm_scan_destination"} + ) def postpone_package(self): return self._response_schema(next_states={"start_single"}) diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 83efe92bcf..d368870c0a 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -35,3 +35,4 @@ from . import test_delivery_select from . import test_location_content_transfer_base from . import test_location_content_transfer_start +from . import test_location_content_transfer_set_destination_all diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index c5ebe0569c..078f40a6ca 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -30,7 +30,8 @@ def setUp(self): with self.work_on_services(menu=self.menu, profile=self.profile) as work: self.service = work.component(usage="location_content_transfer") - def _simulate_pickings_selected(self, pickings): + @classmethod + def _simulate_pickings_selected(cls, pickings): """Create a state as if pickings has been selected ... during a Location content transfer. @@ -41,14 +42,16 @@ def _simulate_pickings_selected(self, pickings): * the qty_done of all their move lines is set to they reserved qty """ - pickings.user_id = self.env.uid + pickings.user_id = cls.env.uid for line in pickings.mapped("move_line_ids"): line.qty_done = line.product_uom_qty def assert_response_start(self, response, message=None): self.assert_response(response, next_state="start", message=message) - def assert_response_scan_destination_all(self, response, pickings, message=None): + def _assert_response_scan_destination_all( + self, state, response, pickings, message=None + ): # this code is repeated from the implementation, not great, but we # mostly want to ensure the selection of pickings is right, and the # data methods have their own tests @@ -56,7 +59,7 @@ def assert_response_scan_destination_all(self, response, pickings, message=None) package_levels = pickings.package_level_ids self.assert_response( response, - next_state="scan_destination_all", + next_state=state, data={ "move_lines": self.data.move_lines(lines), "package_levels": self.data.package_levels(package_levels), @@ -64,6 +67,18 @@ def assert_response_scan_destination_all(self, response, pickings, message=None) message=message, ) + def assert_response_scan_destination_all(self, response, pickings, message=None): + self._assert_response_scan_destination_all( + "scan_destination_all", response, pickings, message=message + ) + + def assert_response_confirm_scan_destination_all( + self, response, pickings, message=None + ): + self._assert_response_scan_destination_all( + "confirm_scan_destination_all", response, pickings, message=message + ) + def assert_response_start_single(self, response, pickings, message=None): sorter = self.service.actions_for("location_content_transfer.sorter") sorter.feed_pickings(pickings) diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py new file mode 100644 index 0000000000..be48916af0 --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -0,0 +1,142 @@ +from .test_location_content_transfer_base import LocationContentTransferCommonCase + + +class LocationContentTransferSetDestinationAllCase(LocationContentTransferCommonCase): + """Tests for endpoint /set_destination_all""" + + # TODO see what can be common + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_lines, in_package=True, location=cls.content_loc + ) + cls._fill_stock_for_moves(picking2.move_lines, location=cls.content_loc) + cls.pickings.action_assign() + cls._simulate_pickings_selected(cls.pickings) + + def assert_all_done(self, destination): + self.assertRecordValues(self.pickings, [{"state": "done"}, {"state": "done"}]) + self.assertRecordValues( + self.pickings.move_line_ids, + [ + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + {"qty_done": 10.0, "state": "done", "location_dest_id": destination.id}, + ], + ) + self.assertRecordValues( + self.picking1.package_level_ids, + [{"is_done": True, "state": "done", "location_dest_id": destination.id}], + ) + + def test_set_destination_all_dest_location_ok(self): + """Scanned destination location valid, moves set to done accepted""" + sub_shelf1 = ( + self.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": self.shelf1.id, + } + ) + ) + response = self.service.dispatch( + "set_destination_all", + params={"location_id": self.content_loc.id, "barcode": sub_shelf1.barcode}, + ) + self.assert_response_start( + response, + message={ + "message_type": "success", + "body": "Content transferred from {}.".format(self.content_loc.name), + }, + ) + self.assert_all_done(sub_shelf1) + + def test_set_destination_all_dest_location_not_found(self): + """Barcode scanned for destination location is not found""" + response = self.service.dispatch( + "set_destination_all", + params={"location_id": self.content_loc.id, "barcode": "NOT_FOUND"}, + ) + self.assert_response_scan_destination_all( + response, self.pickings, message=self.service.msg_store.barcode_not_found() + ) + + def test_set_destination_all_dest_location_need_confirm(self): + """Scanned dest. location != child but in picking type location + + So it needs confirmation. + """ + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + # expected location was shelf1, but shelf2 is valid as still in the + # picking type's default dest location, ask confirmation (second scan) + # from the user + "barcode": self.shelf2.barcode, + }, + ) + self.assert_response_confirm_scan_destination_all(response, self.pickings) + + def test_set_destination_all_dest_location_confirmation(self): + """Scanned dest. location != child but in picking type location: confirm + + use the confirmation flag to confirm + """ + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + # expected location was shelf1, but shelf2 is valid as still in the + # picking type's default dest location, ask confirmation (second scan) + # from the user + "barcode": self.shelf2.barcode, + "confirmation": True, + }, + ) + self.assert_response_start( + response, + message={ + "message_type": "success", + "body": "Content transferred from {}.".format(self.content_loc.name), + }, + ) + self.assert_all_done(self.shelf2) + + def test_set_destination_all_dest_location_invalid(self): + """The scanned destination location is not in the menu's picking types""" + response = self.service.dispatch( + "set_destination_all", + params={ + "location_id": self.content_loc.id, + "barcode": self.dispatch_location.barcode, + }, + ) + self.assert_response_scan_destination_all( + response, + self.pickings, + message=self.service.msg_store.dest_location_not_allowed(), + ) From 04c587020374815b01d7411f4572cb860b62ad9d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 11:51:05 +0200 Subject: [PATCH 05/21] location transfer: implement /go_to_single --- .../services/location_content_transfer.py | 26 ++++++++++--------- ...on_content_transfer_set_destination_all.py | 14 +++++++++- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 51bc7ea5bc..53843f03eb 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -76,11 +76,18 @@ def _response_for_confirm_scan_destination_all(self, pickings, message=None): message=message, ) - def _response_for_start_single(self, location, next_content, message=None): + def _response_for_start_single(self, pickings, message=None): """Transition to the 'start_single' state The client screen shows details of the package level or move line to move. """ + location = pickings.mapped("location_id") + next_content = self._next_content(pickings) + if not next_content: + # TODO test (no more lines) + return self._response_for_start( + message=self.msg_store.location_content_transfer_complete(location) + ) return self._response( next_state="start_single", data=self._data_content_line_for_location(location, next_content), @@ -151,19 +158,10 @@ def _next_content(self, pickings): return next_content def _router_single_or_all_destination(self, pickings, message=None): - location = pickings.mapped("location_id") if len(pickings.mapped("move_line_ids.location_dest_id")) == 1: return self._response_for_scan_destination_all(pickings, message=message) else: - next_content = self._next_content(pickings) - if not next_content: - # TODO test (no more lines) - return self._response_for_start( - message=self.msg_store.location_content_transfer_complete(location) - ) - return self._response_for_start_single( - location, next_content, message=message - ) + return self._response_for_start_single(pickings, message=message) def _domain_recover_pickings(self): return [ @@ -328,7 +326,11 @@ def go_to_single(self, location_id): * start: no remaining lines in the location * start_single: if any line or package level has a different destination """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) def scan_package(self, location_id, package_level_id, barcode): """Scan a package level to move diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py index be48916af0..022fb48e45 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -2,7 +2,12 @@ class LocationContentTransferSetDestinationAllCase(LocationContentTransferCommonCase): - """Tests for endpoint /set_destination_all""" + """Tests for endpoint used from scan_destination_all + + * /set_destination_all + * /go_to_single + + """ # TODO see what can be common @classmethod @@ -140,3 +145,10 @@ def test_set_destination_all_dest_location_invalid(self): self.pickings, message=self.service.msg_store.dest_location_not_allowed(), ) + + def test_go_to_single(self): + """User used to 'split by lines' button to process line per line""" + response = self.service.dispatch( + "go_to_single", params={"location_id": self.content_loc.id} + ) + self.assert_response_start_single(response, self.pickings) From b205b6cb13be11e2e3b48efe487b63722659007c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 19 Jun 2020 14:59:46 +0200 Subject: [PATCH 06/21] location transfer: implement /scan_package --- .../services/location_content_transfer.py | 52 ++++- shopfloor/tests/__init__.py | 1 + .../test_location_content_transfer_base.py | 23 ++ .../test_location_content_transfer_single.py | 203 ++++++++++++++++++ 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 shopfloor/tests/test_location_content_transfer_single.py diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 53843f03eb..643e868da5 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -342,7 +342,57 @@ def scan_package(self, location_id, package_level_id, barcode): * start_single: barcode not found, ... * scan_destination: the barcode matches """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.record_not_found(), + ) + + search = self.actions_for("search") + package = search.package_from_scan(barcode) + if package and package_level.package_id == package: + return self._response_for_scan_destination(location, package_level) + + move_lines = self._find_transfer_move_lines(location) + package_move_lines = package_level.move_line_ids + other_move_lines = move_lines - package_move_lines + + product = search.product_from_scan(barcode) + # Normally the user scan the barcode of the package. But if they scan the + # product and we can be sure it's the correct package, it's tolerated. + if product and product in package_move_lines.mapped("product_id"): + if product in other_move_lines.mapped("product_id") or product.tracking in ( + "lot", + "serial", + ): + # When the product exists in other move lines as raw products + # or part of another package, we can't be sure they scanned + # the correct package, so ask to scan the package. + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message={"message_type": "error", "body": _("Scan the package")}, + ) + else: + return self._response_for_scan_destination(location, package_level) + + lot = search.lot_from_scan(barcode) + if lot and lot in package_move_lines.mapped("lot_id"): + if lot in other_move_lines.mapped("lot_id"): + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message={"message_type": "error", "body": _("Scan the package")}, + ) + else: + return self._response_for_scan_destination(location, package_level) + + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=self.msg_store.barcode_not_found() + ) def scan_line(self, location_id, move_line_id, barcode): """Scan a move line to move diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index d368870c0a..4584cde8dc 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -36,3 +36,4 @@ from . import test_location_content_transfer_base from . import test_location_content_transfer_start from . import test_location_content_transfer_set_destination_all +from . import test_location_content_transfer_single diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index 078f40a6ca..33010fe966 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -89,3 +89,26 @@ def assert_response_start_single(self, response, pickings, message=None): data=self.service._data_content_line_for_location(location, next(sorter)), message=message, ) + + def _assert_response_scan_destination( + self, state, response, next_content, message=None + ): + location = next_content.location_id + self.assert_response( + response, + next_state=state, + data=self.service._data_content_line_for_location(location, next_content), + message=message, + ) + + def assert_response_scan_destination(self, response, next_content, message=None): + self._assert_response_scan_destination( + "scan_destination", response, next_content, message=message + ) + + def assert_response_confirm_scan_destination( + self, response, next_content, message=None + ): + self._assert_response_scan_destination( + "confirm_scan_destination", response, next_content, message=message + ) diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py new file mode 100644 index 0000000000..ef437e7969 --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -0,0 +1,203 @@ +from .test_location_content_transfer_base import LocationContentTransferCommonCase + + +class LocationContentTransferSingleCase(LocationContentTransferCommonCase): + """Tests for endpoint used from state start_single + + * /scan_package + * /scan_line + + """ + + # TODO common with set_destination_all? + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.product_d.tracking = "lot" + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_lines, in_package=True, location=cls.content_loc + ) + cls.product_d_lot = cls.env["stock.production.lot"].create( + {"product_id": cls.product_d.id, "company_id": cls.env.company.id} + ) + cls._fill_stock_for_moves(picking2.move_lines[0], location=cls.content_loc) + cls._fill_stock_for_moves( + picking2.move_lines[1], location=cls.content_loc, in_lot=cls.product_d_lot + ) + cls.pickings.action_assign() + cls._simulate_pickings_selected(cls.pickings) + + def _test_scan_package_ok(self, barcode): + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": barcode, + }, + ) + self.assert_response_scan_destination(response, package_level) + + def test_scan_package_package_ok(self): + package_level = self.picking1.move_line_ids.package_level_id + self._test_scan_package_ok(package_level.package_id.name) + + def test_scan_package_barcode_not_found(self): + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": "NOT_FOUND", + }, + ) + self.assert_response_start_single( + response, self.pickings, message=self.service.msg_store.barcode_not_found() + ) + + def test_scan_package_product_ok(self): + # product_a is in the package and anywhere else so it's + # accepted to check we scanned the correct package + self._test_scan_package_ok(self.product_a.barcode) + + def test_scan_package_product_packaging_ok(self): + # product_a is in the package and anywhere else so it's + # accepted to check we scanned the correct package + self._test_scan_package_ok(self.product_a.packaging_ids[0].barcode) + + def test_scan_package_lot_ok(self): + package_level = self.picking1.move_line_ids.package_level_id + line_product_a = package_level.move_line_ids[0] + self.product_a.tracking = "lot" + line_product_a.lot_id = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + # lot of product_a is in the package and anywhere else so it's + # accepted to check we scanned the correct package + self._test_scan_package_ok(line_product_a.lot_id.name) + + def _test_scan_package_nok(self, pickings, barcode, message): + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.product_a.barcode, + }, + ) + self.assert_response_start_single(response, pickings, message=message) + + def test_scan_package_product_nok_different_package(self): + # add another picking with a package with product a, + # if we scan product A, we can't know for which package it is + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking.move_lines, in_package=True, location=self.content_loc + ) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_product_nok_different_line(self): + # add another picking with a raw line with product a, + # if we scan product A, we can't know which line/package we want + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves(picking.move_lines, location=self.content_loc) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_product_nok_product_tracked(self): + # we scan product_a's barcode but it's tracked by lot + self.product_a.tracking = "lot" + self._test_scan_package_nok( + self.pickings, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_lot_nok_different_package(self): + # add another picking with a package with the lot used in our package, + # if we scan the lot, we can't know for which package it is + package_level = self.picking1.move_line_ids.package_level_id + line_product_a = package_level.move_line_ids[0] + self.product_a.tracking = "lot" + line_product_a.lot_id = lot = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking.move_lines, in_package=True, in_lot=lot, location=self.content_loc + ) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_lot_nok_different_line(self): + # add another picking with a raw line with a lot used in our package, + # if we scan the lot, we can't know which line/package we want + package_level = self.picking1.move_line_ids.package_level_id + line_product_a = package_level.move_line_ids[0] + self.product_a.tracking = "lot" + line_product_a.lot_id = lot = self.env["stock.production.lot"].create( + {"product_id": self.product_a.id, "company_id": self.env.company.id} + ) + picking = self._create_picking(lines=[(self.product_a, 10)]) + self._fill_stock_for_moves( + picking.move_lines, in_lot=lot, location=self.content_loc + ) + picking.action_assign() + self._simulate_pickings_selected(picking) + self._test_scan_package_nok( + self.pickings | picking, + self.product_a.barcode, + {"message_type": "error", "body": "Scan the package"}, + ) + + def test_scan_package_package_level_not_exists(self): + package_level = self.picking1.move_line_ids.package_level_id + package_level_id = package_level.id + package_level.unlink() + response = self.service.dispatch( + "scan_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level_id, + "barcode": self.product_a.barcode, + }, + ) + self.assert_response_start_single( + response, self.pickings, message=self.service.msg_store.record_not_found() + ) From ae4272fa7361acc21e218ecb12bc390e65424927 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 22 Jun 2020 12:13:57 +0200 Subject: [PATCH 07/21] location transfer: implement /scan_line --- .../services/location_content_transfer.py | 32 +++++++- shopfloor/tests/common.py | 2 +- .../test_location_content_transfer_single.py | 74 ++++++++++++++++++- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 643e868da5..21b858477a 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -404,7 +404,37 @@ def scan_line(self, location_id, move_line_id, barcode): * start_single: barcode not found, ... * scan_destination: the barcode matches """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.record_not_found(), + ) + + search = self.actions_for("search") + product = search.product_from_scan(barcode) + if product and product == move_line.product_id: + if product.tracking in ("lot", "serial"): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + else: + return self._response_for_scan_destination(location, move_line) + + lot = search.lot_from_scan(barcode) + if lot and lot == move_line.lot_id: + return self._response_for_scan_destination(location, move_line) + + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=self.msg_store.barcode_not_found() + ) def set_destination_package( self, location_id, package_level_id, barcode, confirmation=False diff --git a/shopfloor/tests/common.py b/shopfloor/tests/common.py index 9fbd131bec..b55dc7f269 100644 --- a/shopfloor/tests/common.py +++ b/shopfloor/tests/common.py @@ -202,7 +202,7 @@ def setUpClassBaseData(cls): .create( { "name": "Box", - "product_id": cls.product_b.id, + "product_id": cls.product_c.id, "barcode": "ProductCBox", } ) diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index ef437e7969..d5b89bcd69 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -102,7 +102,7 @@ def _test_scan_package_nok(self, pickings, barcode, message): params={ "location_id": self.content_loc.id, "package_level_id": package_level.id, - "barcode": self.product_a.barcode, + "barcode": barcode, }, ) self.assert_response_start_single(response, pickings, message=message) @@ -201,3 +201,75 @@ def test_scan_package_package_level_not_exists(self): self.assert_response_start_single( response, self.pickings, message=self.service.msg_store.record_not_found() ) + + def _test_scan_line_ok(self, move_line, barcode): + response = self.service.dispatch( + "scan_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "barcode": barcode, + }, + ) + self.assert_response_scan_destination(response, move_line) + + def test_scan_line_product_ok(self): + move_line = self.picking2.move_line_ids[0] + # check we selected the good line + self.assertEqual(move_line.product_id, self.product_c) + self._test_scan_line_ok(move_line, self.product_c.barcode) + + def test_scan_line_product_packaging_ok(self): + move_line = self.picking2.move_line_ids[0] + # check we selected the good line + self.assertEqual(move_line.product_id, self.product_c) + self._test_scan_line_ok(move_line, self.product_c.packaging_ids[0].barcode) + + def test_scan_line_lot_ok(self): + move_line = self.picking2.move_line_ids[1] + # check we selected the good line (the one with a lot) + self.assertEqual(move_line.product_id, self.product_d) + self._test_scan_line_ok(move_line, self.product_d_lot.name) + + def _test_scan_line_nok(self, pickings, move_line_id, barcode, message): + response = self.service.dispatch( + "scan_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_id, + "barcode": barcode, + }, + ) + self.assert_response_start_single(response, pickings, message=message) + + def test_scan_line_product_nok_product_tracked(self): + # we scan product_d's barcode but it's tracked by lot + move_line = self.picking2.move_line_ids[1] + # check we selected the good line (the one with a lot) + self.assertEqual(move_line.product_id, self.product_d) + self._test_scan_line_nok( + self.pickings, + move_line.id, + self.product_d.barcode, + self.service.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + + def test_scan_line_barcode_not_found(self): + move_line = self.picking2.move_line_ids[0] + self._test_scan_line_nok( + self.pickings, + move_line.id, + "NOT_FOUND", + self.service.msg_store.barcode_not_found(), + ) + + def test_scan_line_move_line_not_exists(self): + move_line = self.picking2.move_line_ids[0] + move_line_id = move_line.id + move_line.unlink() + self._test_scan_line_nok( + self.pickings, + move_line_id, + "NOT_FOUND", + self.service.msg_store.record_not_found(), + ) From df4870ba18db705107b833bc99c7030475febb72 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 23 Jun 2020 13:29:15 +0200 Subject: [PATCH 08/21] location transfer: Add a bit of docstring for /scan_location --- shopfloor/services/location_content_transfer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 21b858477a..76325bf609 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -214,6 +214,12 @@ def scan_location(self, barcode): All the move lines and package levels must have the same picking type. + If the scanned location has no move lines, new move lines to move the + whole content of the location are created if: + + * the menu has the option "Allow to create move(s)" + * the menu is linked to only one picking type. + When move lines and package levels have different destinations, the first line without package level or package level is sent to the client. From 89674e92b2464f8cc8a1a3902b2866805ca8e0d2 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 24 Jun 2020 10:01:34 +0200 Subject: [PATCH 09/21] Add todo --- shopfloor/services/location_content_transfer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 76325bf609..8d0ffca722 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -233,6 +233,9 @@ def scan_location(self, barcode): if not location: return self._response_for_start(message=self.msg_store.barcode_not_found()) move_lines = self._find_location_move_lines(location) + # TODO: Add creation of move lines and package levels when empty, see + # single_pack_transfer.py for creation of package levels (but quants + # without packs need to be created as move lines too here) pickings = move_lines.mapped("picking_id") picking_types = pickings.mapped("picking_type_id") From 41b2e97999c3265a60c9bbf1fee73c5874109fb1 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 30 Jun 2020 18:16:13 +0200 Subject: [PATCH 10/21] location transfer: improve /scan_location by creating moves if needed --- shopfloor/actions/message.py | 12 ++++ shopfloor/demo/shopfloor_menu_demo.xml | 1 + .../services/location_content_transfer.py | 55 +++++++++++++++++-- .../test_location_content_transfer_start.py | 51 +++++++++++++++++ 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index db160ee3f4..c497b46924 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -329,3 +329,15 @@ def recovered_previous_session(self): "message_type": "info", "body": _("Recovered previous session."), } + + def no_lines_to_process(self): + return { + "message_type": "info", + "body": _("No lines to process."), + } + + def location_empty(self, location): + return { + "message_type": "info", + "body": _("Location {} empty").format(location.name), + } diff --git a/shopfloor/demo/shopfloor_menu_demo.xml b/shopfloor/demo/shopfloor_menu_demo.xml index 2880a74a80..4929c516c0 100644 --- a/shopfloor/demo/shopfloor_menu_demo.xml +++ b/shopfloor/demo/shopfloor_menu_demo.xml @@ -39,6 +39,7 @@ Location Content Transfer 60 + location_content_transfer ", 0)] + ) + # create moves for each quant + picking_type = self.work.menu.picking_type_ids + move_vals_list = [] + for quant in quants: + move_vals_list.append( + { + "name": quant.product_id.name, + "company_id": picking_type.company_id.id, + "product_id": quant.product_id.id, + "product_uom": quant.product_uom_id.id, + "product_uom_qty": quant.quantity, + "location_id": location.id, + "location_dest_id": picking_type.default_location_dest_id.id, + "origin": self.work.menu.name, + "picking_type_id": picking_type.id, + } + ) + return self.env["stock.move"].create(move_vals_list) + def scan_location(self, barcode): """Scan start location @@ -233,9 +254,6 @@ def scan_location(self, barcode): if not location: return self._response_for_start(message=self.msg_store.barcode_not_found()) move_lines = self._find_location_move_lines(location) - # TODO: Add creation of move lines and package levels when empty, see - # single_pack_transfer.py for creation of package levels (but quants - # without packs need to be created as move lines too here) pickings = move_lines.mapped("picking_id") picking_types = pickings.mapped("picking_type_id") @@ -253,6 +271,31 @@ def scan_location(self, barcode): "body": _("This location content can't be moved using this menu."), } ) + # If the following criteria are met: + # - no move lines have been found + # - the menu is configured to allow the creation of moves + # - the menu is bind to one picking type + # - scanned location is a child of the picking type source location + # then prepare new stock moves to move goods from the scanned location. + menu = self.work.menu + if ( + not move_lines + and menu.allow_move_create + and len(menu.picking_type_ids) == 1 + and location.is_sublocation_of( + menu.picking_type_ids.default_location_src_id + ) + ): + new_moves = self._create_moves_from_location(location) + new_moves._action_confirm(merge=False) + new_moves._action_assign() + pickings = new_moves.mapped("picking_id") + move_lines = new_moves.move_line_ids + + if not pickings: + return self._response_for_start( + message=self.msg_store.location_empty(location) + ) for line in move_lines: line.qty_done = line.product_uom_qty diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py index 378048a7bc..35b01f465d 100644 --- a/shopfloor/tests/test_location_content_transfer_start.py +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -172,3 +172,54 @@ def test_scan_location_wrong_picking_type(self): "body": "This location content can't be moved using this menu.", }, ) + + def test_scan_location_create_moves(self): + """The scanned location has no move lines but has some quants to move.""" + picking_type = self.menu.picking_type_ids + # product_a alone + self.env["stock.quant"]._update_available_quantity( + self.product_a, self.content_loc, 10, + ) + # product_b in a package + package = self.env["stock.quant.package"].create({}) + self.env["stock.quant"]._update_available_quantity( + self.product_b, self.content_loc, 10, package_id=package + ) + # product_c & product_d in a package + package2 = self.env["stock.quant.package"].create({}) + self.env["stock.quant"]._update_available_quantity( + self.product_c, self.content_loc, 5, package_id=package2 + ) + self.env["stock.quant"]._update_available_quantity( + self.product_d, self.content_loc, 5, package_id=package2 + ) + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + picking = self.env["stock.picking"].search( + [("picking_type_id", "=", picking_type.id)] + ) + self.assertEqual(len(picking), 1) + self.assert_response_scan_destination_all(response, picking) + move_line_id = response["data"]["scan_destination_all"]["move_lines"][0]["id"] + package_levels = response["data"]["scan_destination_all"]["package_levels"] + self.assertIn(move_line_id, picking.move_line_ids.ids) + self.assertEqual(package_levels[0]["id"], picking.package_level_ids[0].id) + self.assertEqual(package_levels[0]["package"]["id"], package.id) + self.assertEqual(package_levels[1]["id"], picking.package_level_ids[1].id) + self.assertEqual(package_levels[1]["package"]["id"], package2.id) + # product_a in a move line without package + self.assertEqual( + picking.move_line_ids_without_package.mapped("product_id"), self.product_a + ) + # all other products are in package levels + self.assertEqual( + picking.package_level_ids.mapped("package_id.quant_ids.product_id"), + self.product_b | self.product_c | self.product_d, + ) + # all products are in move lines + self.assertEqual( + picking.move_line_ids.mapped("product_id"), + self.product_a | self.product_b | self.product_c | self.product_d, + ) + self.assertEqual(picking.state, "assigned") From 2c0f23295d095185500f51b920f116238adaebf0 Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 1 Jul 2020 11:56:47 +0200 Subject: [PATCH 11/21] location transfer: add a test for /scan_package if location doesn't exist --- .../tests/test_location_content_transfer_single.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index d5b89bcd69..1694089fa3 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -56,6 +56,19 @@ def _test_scan_package_ok(self, barcode): ) self.assert_response_scan_destination(response, package_level) + def test_scan_package_location_not_found(self): + response = self.service.dispatch( + "scan_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": 42, + "barcode": "TEST", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + def test_scan_package_package_ok(self): package_level = self.picking1.move_line_ids.package_level_id self._test_scan_package_ok(package_level.package_id.name) From 07c81ff6fd79315a7737ba0e1c07a5c20c3fd28d Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 1 Jul 2020 11:58:12 +0200 Subject: [PATCH 12/21] location transfer: improve /go_to_single to redirect on 'start' screen if there is no move lines to process --- .../services/location_content_transfer.py | 4 ++++ ...on_content_transfer_set_destination_all.py | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 0844d138bf..87479e5e6f 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -382,6 +382,10 @@ def go_to_single(self, location_id): if not location.exists(): return self._response_for_start(message=self.msg_store.record_not_found()) move_lines = self._find_transfer_move_lines(location) + if not move_lines: + return self._response_for_start( + message=self.msg_store.no_lines_to_process() + ) return self._response_for_start_single(move_lines.mapped("picking_id")) def scan_package(self, location_id, package_level_id, barcode): diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py index 022fb48e45..c40f566250 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -152,3 +152,25 @@ def test_go_to_single(self): "go_to_single", params={"location_id": self.content_loc.id} ) self.assert_response_start_single(response, self.pickings) + + +class LocationContentTransferSetDestinationAllSpecialCase( + LocationContentTransferCommonCase +): + """Tests for endpoint used from scan_destination_all (special cases without setup) + + * /set_destination_all + * /go_to_single + + """ + + def test_go_to_single_no_lines_to_process(self): + """User used to 'split by lines' button to process line per line, + but no lines to process. + """ + response = self.service.dispatch( + "go_to_single", params={"location_id": self.content_loc.id} + ) + self.assert_response_start( + response, message=self.service.msg_store.no_lines_to_process() + ) From 70c0d8f2bdd40211d5019b1b5551f24dd422b053 Mon Sep 17 00:00:00 2001 From: sebalix Date: Wed, 1 Jul 2020 18:20:01 +0200 Subject: [PATCH 13/21] location transfer: implement /set_destination_package and /set_destination_line endpoints --- shopfloor/models/shopfloor_menu.py | 5 +- .../services/location_content_transfer.py | 95 +++- shopfloor/tests/__init__.py | 1 + ...ransfer_set_destination_package_or_line.py | 506 ++++++++++++++++++ 4 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 269d87aa01..51f97a56ff 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -6,7 +6,10 @@ class ShopfloorMenu(models.Model): _description = "Menu displayed in the scanner application" _order = "sequence" - _scenario_allowing_create_moves = ("single_pack_transfer",) + _scenario_allowing_create_moves = ( + "single_pack_transfer", + "location_content_transfer", + ) name = fields.Char(translate=True) sequence = fields.Integer() diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 87479e5e6f..bcafcd9a08 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -512,8 +512,52 @@ def set_destination_package( Transitions: * scan_destination: invalid destination or could not * start_single: continue with the next package level / line + * start: if there is no more package level / line to process """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) + search = self.actions_for("search") + scanned_location = search.location_from_scan(barcode) + if not scanned_location: + return self._response_for_scan_destination( + location, package_level, message=self.msg_store.no_location_found() + ) + if not scanned_location.is_sublocation_of( + package_level.picking_id.picking_type_id.default_location_dest_id + ): + return self._response_for_scan_destination( + location, + package_level, + message=self.msg_store.dest_location_not_allowed(), + ) + if not scanned_location.is_sublocation_of(package_level.location_dest_id): + if not confirmation: + return self._response_for_confirm_scan_destination( + location, package_level + ) + package_move_lines = package_level.move_line_ids + package_moves = package_move_lines.mapped("move_id") + for package_move in package_moves: + # Check if there is no other lines linked to the move others than + # the lines related to the package itself. In such case we have to + # split the move to process only the lines related to the package. + other_move_lines = package_move.move_line_ids - package_move_lines + if other_move_lines: + qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) + backorder_move_id = package_move._split(qty_to_split) + backorder_move = self.env["stock.move"].browse(backorder_move_id) + backorder_move.move_line_ids = other_move_lines + backorder_move._action_assign() + if package_move_lines == package_moves.mapped("move_line_ids"): + package_level.location_dest_id = scanned_location + package_moves.with_context(_sf_no_backorder=True)._action_done() + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) def set_destination_line( self, location_id, move_line_id, quantity, barcode, confirmation=False @@ -536,8 +580,55 @@ def set_destination_line( Transitions: * scan_destination: invalid destination or could not * start_single: continue with the next package level / line + * start: if there is no more package level / line to process """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) + search = self.actions_for("search") + scanned_location = search.location_from_scan(barcode) + if not scanned_location: + return self._response_for_scan_destination( + location, move_line, message=self.msg_store.no_location_found() + ) + if not scanned_location.is_sublocation_of( + move_line.picking_id.picking_type_id.default_location_dest_id + ): + return self._response_for_scan_destination( + location, move_line, message=self.msg_store.dest_location_not_allowed() + ) + if not scanned_location.is_sublocation_of(move_line.location_dest_id): + if not confirmation: + return self._response_for_confirm_scan_destination(location, move_line) + if quantity < move_line.product_uom_qty: + # Update the current move line quantity and + # put the scanned qty (the move line) in its own move + # (by splitting the current one) + move_line.product_uom_qty = move_line.qty_done = quantity + current_move = move_line.move_id + new_move_id = current_move._split(quantity) + new_move = self.env["stock.move"].browse(new_move_id) + new_move.move_line_ids = move_line + # Ensure that the remaining qty to process is reserved as before + current_move._recompute_state() + (new_move | current_move)._action_assign() + for remaining_move_line in current_move.move_line_ids: + remaining_move_line.qty_done = remaining_move_line.product_uom_qty + other_move_lines = move_line.move_id.move_line_ids - move_line + if other_move_lines: + qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) + backorder_move_id = move_line.move_id._split(qty_to_split) + backorder_move = self.env["stock.move"].browse(backorder_move_id) + backorder_move.move_line_ids = other_move_lines + backorder_move._action_assign() + move_line.location_dest_id = scanned_location + move_line.move_id.with_context(_sf_no_backorder=True)._action_done() + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) def postpone_package(self, location_id, package_level_id): """Mark a package level as postponed and return the next level/line diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 4584cde8dc..61dfbc481b 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -37,3 +37,4 @@ from . import test_location_content_transfer_start from . import test_location_content_transfer_set_destination_all from . import test_location_content_transfer_single +from . import test_location_content_transfer_set_destination_package_or_line diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py new file mode 100644 index 0000000000..074c8d20cc --- /dev/null +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -0,0 +1,506 @@ +from .test_location_content_transfer_base import LocationContentTransferCommonCase + + +class LocationContentTransferSetDestinationXCase(LocationContentTransferCommonCase): + """Tests for endpoint used from scan_destination + + * /set_destination_package + * /set_destination_line + + """ + + # TODO see what can be common + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + cls.product_b + cls.product_c + cls.product_d + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking1 = picking1 = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.picking2 = picking2 = cls._create_picking( + lines=[(cls.product_c, 10), (cls.product_d, 10)] + ) + cls.pickings = picking1 | picking2 + cls._fill_stock_for_moves( + picking1.move_lines, in_package=True, location=cls.content_loc + ) + cls._fill_stock_for_moves(picking2.move_lines, location=cls.content_loc) + cls.pickings.action_assign() + cls._simulate_pickings_selected(cls.pickings) + cls.dest_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": cls.shelf1.id, + } + ) + ) + + def test_set_destination_package_wrong_parameters(self): + """Wrong 'location' and 'package_level_id' parameters, redirect the + user to the 'start' screen. + """ + package_level = self.picking1.package_level_ids[0] + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": package_level.id, + "barcode": "TEST", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": 1234567890, # Doesn't exist + "barcode": "TEST", + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_set_destination_package_dest_location_nok(self): + """Scanned destination location not valid, redirect to 'scan_destination'.""" + package_level = self.picking1.package_level_ids[0] + # Unknown destination location + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": "UNKNOWN_LOCATION", + }, + ) + self.assert_response_scan_destination( + response, package_level, message=self.service.msg_store.no_location_found(), + ) + # Destination location not allowed + customer_location = self.env.ref("stock.stock_location_customers") + customer_location.sudo().barcode = "CUSTOMER" + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": customer_location.barcode, + }, + ) + self.assert_response_scan_destination( + response, + package_level, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_set_destination_package_dest_location_to_confirm(self): + """Scanned destination location valid, but need a confirmation.""" + package_level = self.picking1.package_level_ids[0] + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.env.ref("stock.stock_location_14").barcode, + }, + ) + self.assert_response_confirm_scan_destination( + response, package_level, + ) + + def test_set_destination_package_dest_location_ok(self): + """Scanned destination location valid, moves set to done.""" + package_level = self.picking1.package_level_ids[0] + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + for move in package_level.move_line_ids.mapped("move_id"): + self.assertEqual(move.state, "done") + + def test_set_destination_line_wrong_parameters(self): + """Wrong 'location' and 'move_line_id' parameters, redirect the + user to the 'start' screen. + """ + move_line = self.picking2.move_line_ids[0] + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": 1234567890, # Doesn't exist + "move_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": "TEST", + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": 1234567890, # Doesn't exist + "quantity": move_line.product_uom_qty, + "barcode": "TEST", + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_set_destination_line_dest_location_nok(self): + """Scanned destination location not valid, redirect to 'scan_destination'.""" + move_line = self.picking2.move_line_ids[0] + # Unknown destination location + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": "UNKNOWN_LOCATION", + }, + ) + self.assert_response_scan_destination( + response, move_line, message=self.service.msg_store.no_location_found(), + ) + # Destination location not allowed + customer_location = self.env.ref("stock.stock_location_customers") + customer_location.sudo().barcode = "CUSTOMER" + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": customer_location.barcode, + }, + ) + self.assert_response_scan_destination( + response, + move_line, + message=self.service.msg_store.dest_location_not_allowed(), + ) + + def test_set_destination_line_dest_location_to_confirm(self): + """Scanned destination location valid, but need a confirmation.""" + move_line = self.picking2.move_line_ids[0] + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": self.env.ref("stock.stock_location_14").barcode, + }, + ) + self.assert_response_confirm_scan_destination(response, move_line) + + def test_set_destination_line_dest_location_ok(self): + """Scanned destination location valid, moves set to done.""" + move_line = self.picking2.move_line_ids[0] + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + self.assertEqual(move_line.move_id.state, "done") + self.assertEqual(move_line.picking_id.state, "assigned") + + def test_set_destination_line_partial_qty(self): + """Scanned destination location with partial qty, but related moves + has to be splitted. + """ + move_line_c = self.picking2.move_line_ids.filtered( + lambda m: m.product_id == self.product_c + ) + self.assertEqual(move_line_c.product_uom_qty, 10) + self.assertEqual(move_line_c.qty_done, 10) + # Scan partial qty (6/10) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_c.id, + "quantity": move_line_c.product_uom_qty - 4, # Scan 6 qty + "barcode": self.dest_location.barcode, + }, + ) + # Check move line data + self.assertEqual(move_line_c.move_id.product_uom_qty, 6) + self.assertEqual(move_line_c.product_uom_qty, 0) + self.assertEqual(move_line_c.qty_done, 6) + self.assertEqual(move_line_c.state, "done") + # Check the new move created to handle the remaining qty + move_product_c_splitted = self.picking2.move_lines.filtered( + lambda m: m.product_id == self.product_c and m.state == "assigned" + ) + self.assertEqual(move_product_c_splitted.state, "assigned") + self.assertEqual(move_product_c_splitted.product_id, self.product_c) + self.assertEqual(move_product_c_splitted.product_uom_qty, 4) + self.assertEqual(move_product_c_splitted.move_line_ids.product_uom_qty, 4) + self.assertEqual(move_product_c_splitted.move_line_ids.qty_done, 4) + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + self.assertEqual(move_line_c.move_id.state, "done") + # Scan remaining qty (4/10) + remaining_move_line_c = move_product_c_splitted.move_line_ids + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": remaining_move_line_c.id, + "quantity": remaining_move_line_c.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + # Check move line data + self.assertEqual(remaining_move_line_c.move_id.product_uom_qty, 4) + self.assertEqual(remaining_move_line_c.product_uom_qty, 0) + self.assertEqual(remaining_move_line_c.qty_done, 4) + self.assertEqual(remaining_move_line_c.state, "done") + # All move lines related to product_c are now done + moves_product_c = self.picking2.move_lines.filtered( + lambda m: m.product_id == self.product_c + ) + moves_product_c_done = all(move.state == "done" for move in moves_product_c) + self.assertTrue(moves_product_c_done) + moves_product_c_qty_done = sum([move.quantity_done for move in moves_product_c]) + self.assertEqual(moves_product_c_qty_done, 10) + # The picking is still not done as product_d hasn't been processed + self.assertEqual(self.picking2.state, "assigned") + # Let scan product_d quantity and check picking state + move_line_d = self.picking2.move_line_ids.filtered( + lambda m: m.product_id == self.product_d + ) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line_d.id, + "quantity": move_line_d.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + self.assertEqual(move_line_d.move_id.product_uom_qty, 10) + self.assertEqual(move_line_d.product_uom_qty, 0) + self.assertEqual(move_line_d.qty_done, 10) + self.assertEqual(move_line_d.state, "done") + self.assertEqual(self.picking2.state, "done") + + +class LocationContentTransferSetDestinationXSpecialCase( + LocationContentTransferCommonCase +): + """Tests for endpoint used from scan_destination (special cases) + + * /set_destination_package + * /set_destination_line + + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.move_product_a = cls.picking.move_lines.filtered( + lambda m: m.product_id == cls.product_a + ) + cls.move_product_b = cls.picking.move_lines.filtered( + lambda m: m.product_id == cls.product_b + ) + # Change the initial demand of product_a to get two move lines for + # reserved qties: + # - 10 from the package + # - 5 from the qty without package + cls._fill_stock_for_moves( + cls.move_product_a, in_package=True, location=cls.content_loc + ) + cls.move_product_a.product_uom_qty = 15 + cls._update_qty_in_location( + cls.picking.location_id, cls.product_a, 5, + ) + # Put product_b quantities in two different source locations to get + # two stock move lines (6 and 4 to satisfy 10 qties) + cls._update_qty_in_location(cls.picking.location_id, cls.product_b, 6) + cls._update_qty_in_location(cls.content_loc, cls.product_b, 4) + # Reserve quantities + cls.picking.action_assign() + cls._simulate_pickings_selected(cls.picking) + cls.dest_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Sub Shelf 1", + "barcode": "subshelf1", + "location_id": cls.shelf1.id, + } + ) + ) + + def test_set_destination_package_split_move(self): + """Scanned destination location valid for a package, but related moves + has to be splitted because it is linked to additional move lines. + """ + self.assertEqual(len(self.picking.move_lines), 2) + self.assertEqual(len(self.move_product_a.move_line_ids), 2) + package_level = self.picking.package_level_ids[0] + response = self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + # Check the picking data + self.assertEqual(package_level.location_dest_id, self.dest_location) + for move_line in package_level.move_line_ids: + self.assertEqual(move_line.location_dest_id, self.dest_location) + moves_product_a = self.picking.move_lines.filtered( + lambda m: m.product_id == self.product_a + ) + self.assertEqual(len(self.picking.move_lines), 3) + self.assertEqual(len(moves_product_a), 2) + for move in moves_product_a: + self.assertEqual(len(move.move_line_ids), 1) + move_lines_wo_pkg = self.picking.move_line_ids_without_package + move_lines_wo_pkg_states = set(move_lines_wo_pkg.mapped("state")) + self.assertEqual(len(move_lines_wo_pkg_states), 1) + self.assertEqual(move_lines_wo_pkg_states.pop(), "assigned") + self.assertEqual(self.picking.package_level_ids.state, "done") + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + # self.assert_response_start( + # response, + # # FIXME: message needs to be refactored to avoid "False" as location name + # message=self.service.msg_store.location_content_transfer_complete( + # self.env["stock.location"] + # ), + # ) + + def test_set_destination_line_split_move(self): + """Scanned destination location valid for a move line, but related moves + has to be splitted because it is linked to additional move lines. + """ + self.assertEqual(len(self.picking.move_lines), 2) + self.assertEqual(len(self.move_product_b.move_line_ids), 2) + move_line = self.move_product_b.move_line_ids.filtered( + lambda ml: ml.product_uom_qty == 6 + ) + response = self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + # Check the picking data + self.assertEqual(self.picking.state, "assigned") + self.assertEqual(move_line.move_id.product_uom_qty, 6) + self.assertEqual(move_line.product_uom_qty, 0) + self.assertEqual(move_line.qty_done, 6) + self.assertEqual(move_line.location_dest_id, self.dest_location) + moves_product_b = self.picking.move_lines.filtered( + lambda m: m.product_id == self.product_b + ) + self.assertEqual(len(self.picking.move_lines), 3) + self.assertEqual(len(moves_product_b), 2) + for move in moves_product_b: + self.assertEqual(len(move.move_line_ids), 1) + move_lines_wo_pkg = self.picking.move_line_ids_without_package + move_lines_wo_pkg_states = set(move_lines_wo_pkg.mapped("state")) + self.assertEqual(len(move_lines_wo_pkg_states), 2) + self.assertIn("assigned", move_lines_wo_pkg_states) + self.assertIn("done", move_lines_wo_pkg_states) + self.assertEqual(move_line.state, "done") + remaining_move = self.picking.move_lines.filtered( + lambda m: move_line.move_id != m and m.product_id == self.product_b + ) + self.assertEqual(remaining_move.state, "assigned") + self.assertEqual(remaining_move.product_uom_qty, 4) + self.assertEqual(remaining_move.move_line_ids.product_uom_qty, 4) + self.assertEqual(remaining_move.move_line_ids.qty_done, 4) + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single(response, move_lines.mapped("picking_id")) + # Process the other move lines (lines w/o package + package levels) + # to check the picking state + remaining_move_lines = self.picking.move_line_ids_without_package.filtered( + lambda ml: ml.state == "assigned" + ) + for ml in remaining_move_lines: + self.service.dispatch( + "set_destination_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": ml.id, + "quantity": ml.product_uom_qty, + "barcode": self.dest_location.barcode, + }, + ) + self.assertEqual(self.picking.state, "assigned") + package_level = self.picking.package_level_ids[0] + self.service.dispatch( + "set_destination_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + "barcode": self.dest_location.barcode, + }, + ) + self.assertEqual(self.picking.state, "done") From 413b57377492fff1f38eed6d0b07f2742b034225 Mon Sep 17 00:00:00 2001 From: sebalix Date: Mon, 6 Jul 2020 12:01:16 +0200 Subject: [PATCH 14/21] location transfer: implement /postpone_package and /postpone_line endpoints --- .../location_content_transfer_sorter.py | 3 +- shopfloor/models/stock_package_level.py | 9 +- .../services/location_content_transfer.py | 24 +++-- .../test_location_content_transfer_single.py | 100 ++++++++++++++++++ 4 files changed, 126 insertions(+), 10 deletions(-) diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index a79bc69242..80ca0488fe 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -32,7 +32,8 @@ def _sort_key(content): # content can be either a move line, either a package # level return ( - # TODO add postponed (need to be added to package_level) + # postponed content after other contents + int(content.shopfloor_postponed), # sort by similar destination content.location_dest_id.complete_name, # lines before packages (if we have raw products and packages, raw diff --git a/shopfloor/models/stock_package_level.py b/shopfloor/models/stock_package_level.py index 1db120cc2f..1aa691c6e9 100644 --- a/shopfloor/models/stock_package_level.py +++ b/shopfloor/models/stock_package_level.py @@ -1,9 +1,16 @@ -from odoo import models +from odoo import fields, models class StockPackageLevel(models.Model): _inherit = "stock.package_level" + shopfloor_postponed = fields.Boolean( + default=False, + copy=False, + help="Technical field. " + "Indicates if a the package level has been postponed in a barcode scenario.", + ) + def replace_package(self, new_package): """Replace a package on an assigned package level and related records diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index bcafcd9a08..b42c3db554 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -633,24 +633,32 @@ def set_destination_line( def postpone_package(self, location_id, package_level_id): """Mark a package level as postponed and return the next level/line - NOTE for implementation: Use the field "shopfloor_postponed", which has - to be included in the sort to get the next lines. - Transitions: * start_single: continue with the next package level / line """ - return self._response() + location = self.env["stock.location"].browse(location_id) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + if package_level.exists(): + package_level.shopfloor_postponed = True + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) def postpone_line(self, location_id, move_line_id): """Mark a move line as postponed and return the next level/line - NOTE for implementation: Use the field "shopfloor_postponed", which has - to be included in the sort to get the next lines. - Transitions: * start_single: continue with the next package level / line """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if move_line.exists(): + move_line.shopfloor_postponed = True + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) def stock_out_package(self, location_id, package_level_id): """Declare a stock out on a package level diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index 1694089fa3..5d0f7bb5b8 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -6,6 +6,8 @@ class LocationContentTransferSingleCase(LocationContentTransferCommonCase): * /scan_package * /scan_line + * /postpone_package + * /postpone_line """ @@ -286,3 +288,101 @@ def test_scan_line_move_line_not_exists(self): "NOT_FOUND", self.service.msg_store.record_not_found(), ) + + def test_postpone_package_wrong_parameters(self): + """Wrong 'location_id' and 'package_level_id' parameters, redirect the + user to the 'start' screen. + """ + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "postpone_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": package_level.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "postpone_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_postpone_package_ok(self): + package_level = self.picking1.move_line_ids.package_level_id + self.assertFalse(package_level.shopfloor_postponed) + response = self.service.dispatch( + "postpone_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + self.assertTrue(package_level.shopfloor_postponed) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_postpone_sorter(self): + move_line = self.picking2.move_line_ids[0] + move_lines = self.service._find_transfer_move_lines(self.content_loc) + pickings = move_lines.mapped("picking_id") + sorter = self.service.actions_for("location_content_transfer.sorter") + sorter.feed_pickings(pickings) + content_sorted1 = list(sorter) + self.service.dispatch( + "postpone_line", + params={"location_id": self.content_loc.id, "move_line_id": move_line.id}, + ) + sorter.sort() + content_sorted2 = list(sorter) + self.assertTrue(content_sorted1 != content_sorted2) + + def test_postpone_line_wrong_parameters(self): + """Wrong 'location_id' and 'move_line_id' parameters, redirect the + user to the 'start' screen. + """ + move_line = self.picking2.move_line_ids[0] + response = self.service.dispatch( + "postpone_line", + params={ + "location_id": 1234567890, # Doesn't exist + "move_line_id": move_line.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "postpone_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_postpone_line_ok(self): + move_line = self.picking2.move_line_ids[0] + self.assertFalse(move_line.shopfloor_postponed) + response = self.service.dispatch( + "postpone_line", + params={"location_id": self.content_loc.id, "move_line_id": move_line.id}, + ) + self.assertTrue(move_line.shopfloor_postponed) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) From 512c378096f7ecaee00cc328b38aaf02a0751e9c Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 7 Jul 2020 11:42:07 +0200 Subject: [PATCH 15/21] location transfer: implement /stock_out_package and /stock_out_line endpoints --- .../services/location_content_transfer.py | 103 ++++++-- .../test_location_content_transfer_single.py | 229 ++++++++++++++++++ 2 files changed, 313 insertions(+), 19 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index b42c3db554..bc48d5a891 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -325,6 +325,20 @@ def _set_destination_lines(self, pickings, move_lines, dest_location): move_lines.package_level_id.location_dest_id = dest_location pickings.action_done() + def _split_other_move_lines(self, move, move_lines): + """Substract `move_lines` from `move.move_line_ids` and put the result + in a new move. + """ + other_move_lines = move.move_line_ids - move_lines + if other_move_lines: + qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) + backorder_move_id = move._split(qty_to_split) + backorder_move = self.env["stock.move"].browse(backorder_move_id) + backorder_move.move_line_ids = other_move_lines + backorder_move._action_assign() + return backorder_move + return False + def set_destination_all(self, location_id, barcode, confirmation=False): """Scan destination location for all the moves of the location @@ -546,16 +560,9 @@ def set_destination_package( # Check if there is no other lines linked to the move others than # the lines related to the package itself. In such case we have to # split the move to process only the lines related to the package. - other_move_lines = package_move.move_line_ids - package_move_lines - if other_move_lines: - qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) - backorder_move_id = package_move._split(qty_to_split) - backorder_move = self.env["stock.move"].browse(backorder_move_id) - backorder_move.move_line_ids = other_move_lines - backorder_move._action_assign() - if package_move_lines == package_moves.mapped("move_line_ids"): - package_level.location_dest_id = scanned_location - package_moves.with_context(_sf_no_backorder=True)._action_done() + self._split_other_move_lines(package_move, package_move_lines) + package_level.location_dest_id = scanned_location + package_moves.with_context(_sf_no_backorder=True)._action_done() move_lines = self._find_transfer_move_lines(location) return self._response_for_start_single(move_lines.mapped("picking_id")) @@ -618,13 +625,7 @@ def set_destination_line( (new_move | current_move)._action_assign() for remaining_move_line in current_move.move_line_ids: remaining_move_line.qty_done = remaining_move_line.product_uom_qty - other_move_lines = move_line.move_id.move_line_ids - move_line - if other_move_lines: - qty_to_split = sum(other_move_lines.mapped("product_uom_qty")) - backorder_move_id = move_line.move_id._split(qty_to_split) - backorder_move = self.env["stock.move"].browse(backorder_move_id) - backorder_move.move_line_ids = other_move_lines - backorder_move._action_assign() + self._split_other_move_lines(move_line.move_id, move_line) move_line.location_dest_id = scanned_location move_line.move_id.with_context(_sf_no_backorder=True)._action_done() move_lines = self._find_transfer_move_lines(location) @@ -675,7 +676,48 @@ def stock_out_package(self, location_id, package_level_id): * start: no more content to move * start_single: continue with the next package level / line """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + package_level = self.env["stock.package_level"].browse(package_level_id) + if not package_level.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) + inventory = self.actions_for("inventory") + package_move_lines = package_level.move_line_ids + package_moves = package_move_lines.mapped("move_id") + for package_move in package_moves: + # Check if there is no other lines linked to the move others than + # the lines related to the package itself. In such case we have to + # split the move to process only the lines related to the package. + self._split_other_move_lines(package_move, package_move_lines) + lot = package_move.move_line_ids.lot_id + package_move._do_unreserve() + package_move._recompute_state() + # Create an inventory at 0 in the move's source location + inventory.create_stock_issue( + package_move, location, package_level.package_id, lot + ) + # Create a draft inventory to control stock + inventory.create_control_stock( + location, package_move.product_id, package_level.package_id, lot + ) + package_move._action_cancel() + # remove the package level (this is what does the `picking.do_unreserve()` + # method, but here we want to unreserve+unlink this package alone) + assert package_level.state == "draft", "Package level has to be in draft" + if package_level.state != "draft": + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single( + move_lines.mapped("picking_id"), + message={ + "message_type": "error", + "body": _("Package level has to be in draft"), + }, + ) + package_level.unlink() + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) def stock_out_line(self, location_id, move_line_id): """Declare a stock out on a move line @@ -692,7 +734,30 @@ def stock_out_line(self, location_id, move_line_id): * start: no more content to move * start_single: continue with the next package level / line """ - return self._response() + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + move_line = self.env["stock.move.line"].browse(move_line_id) + if not move_line.exists(): + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) + inventory = self.actions_for("inventory") + self._split_other_move_lines(move_line.move_id, move_line) + move_line_src_location = move_line.location_id + move = move_line.move_id + package = move_line.package_id + lot = move_line.lot_id + move._do_unreserve() + move._recompute_state() + # Create an inventory at 0 in the move's source location + inventory.create_stock_issue(move, move_line_src_location, package, lot) + # Create a draft inventory to control stock + inventory.create_control_stock( + move_line_src_location, move.product_id, package, lot + ) + move._action_cancel() + move_lines = self._find_transfer_move_lines(location) + return self._response_for_start_single(move_lines.mapped("picking_id")) class ShopfloorLocationContentTransferValidator(Component): diff --git a/shopfloor/tests/test_location_content_transfer_single.py b/shopfloor/tests/test_location_content_transfer_single.py index 5d0f7bb5b8..599b6aabd7 100644 --- a/shopfloor/tests/test_location_content_transfer_single.py +++ b/shopfloor/tests/test_location_content_transfer_single.py @@ -386,3 +386,232 @@ def test_postpone_line_ok(self): self.assert_response_start_single( response, move_lines.mapped("picking_id"), ) + + def test_stock_out_package_wrong_parameters(self): + """Wrong 'location_id' and 'package_level_id' parameters, redirect the + user to the 'start' screen. + """ + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": 1234567890, # Doesn't exist + "package_level_id": package_level.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_stock_out_package_ok(self): + """Declare a stock out on a package_level.""" + package_level = self.picking1.move_line_ids.package_level_id + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_stock_out_line_wrong_parameters(self): + """Wrong 'location_id' and 'move_line_id' parameters, redirect the + user to the 'start' screen. + """ + move_line = self.picking2.move_line_ids[0] + response = self.service.dispatch( + "stock_out_line", + params={ + "location_id": 1234567890, # Doesn't exist + "move_line_id": move_line.id, + }, + ) + self.assert_response_start( + response, message=self.service.msg_store.record_not_found() + ) + response = self.service.dispatch( + "stock_out_line", + params={ + "location_id": self.content_loc.id, + "move_line_id": 1234567890, # Doesn't exist + }, + ) + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + +class LocationContentTransferSingleSpecialCase(LocationContentTransferCommonCase): + """Tests for endpoint used from state start_single (special cases) + + * /stock_out_package + * /stock_out_line + + """ + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + products = cls.product_a | cls.product_b + for product in products: + cls.env["stock.putaway.rule"].sudo().create( + { + "product_id": product.id, + "location_in_id": cls.stock_location.id, + "location_out_id": cls.shelf1.id, + } + ) + + cls.picking = cls._create_picking( + lines=[(cls.product_a, 10), (cls.product_b, 10)] + ) + cls.move_product_a = cls.picking.move_lines.filtered( + lambda m: m.product_id == cls.product_a + ) + cls.move_product_b = cls.picking.move_lines.filtered( + lambda m: m.product_id == cls.product_b + ) + # Change the initial demand of product_a to get two move lines for + # reserved qties: + # - 10 from the package + # - 5 from the qty without package + cls._fill_stock_for_moves( + cls.move_product_a, in_package=True, location=cls.content_loc + ) + cls.move_product_a.product_uom_qty = 15 + cls._update_qty_in_location( + cls.content_loc, cls.product_a, 5, + ) + # Put product_b quantities in two different source locations to get + # two stock move lines (6 and 4 to satisfy 10 qties) + cls._update_qty_in_location(cls.picking.location_id, cls.product_b, 6) + cls._update_qty_in_location(cls.content_loc, cls.product_b, 4) + # Reserve quantities + cls.picking.action_assign() + cls._simulate_pickings_selected(cls.picking) + + def test_stock_out_package_split_move(self): + """Declare a stock out on a package_level related to moves containing + other unrelated move lines. + """ + package_level = self.picking.move_line_ids.package_level_id + self.assertEqual(self.product_a.qty_available, 15) + response = self.service.dispatch( + "stock_out_package", + params={ + "location_id": self.content_loc.id, + "package_level_id": package_level.id, + }, + ) + # Check the picking data + self.assertFalse(package_level.exists()) + moves_product_a = self.picking.move_lines.filtered( + lambda m: m.product_id == self.product_a + ) + self.assertEqual(len(moves_product_a), 2) + move_product_a = moves_product_a.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(len(move_product_a), 1) + self.assertEqual(move_product_a.state, "assigned") + self.assertEqual(len(move_product_a.move_line_ids), 1) + # Check the inventories + stock_issue_inventory = self.env["stock.inventory"].search( + [ + ("line_ids.location_id", "=", self.content_loc.id), + ("line_ids.product_id", "=", self.product_a.id), + ("state", "=", "done"), + ] + ) + self.assertTrue(stock_issue_inventory) + stock_issue_inventory_line = stock_issue_inventory.line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # 5/15 remaining + self.assertEqual(stock_issue_inventory_line.product_qty, 0) + self.assertEqual(self.product_a.qty_available, 5) + control_inventory = self.env["stock.inventory"].search( + [ + ("location_ids", "in", self.content_loc.id), + ("product_ids", "in", self.product_a.id), + ("state", "in", ("draft", "confirm")), + ] + ) + self.assertTrue(control_inventory) + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) + + def test_stock_out_line_split_move(self): + """Declare a stock out on a move line related to moves containing + other move lines. + """ + self.assertEqual(len(self.picking.move_lines), 2) + self.assertEqual(len(self.move_product_b.move_line_ids), 2) + move_line = self.move_product_b.move_line_ids.filtered( + lambda ml: ml.product_uom_qty == 4 # 4/10 to stock out + ) + self.assertEqual(self.product_b.qty_available, 10) + response = self.service.dispatch( + "stock_out_line", + params={"location_id": self.content_loc.id, "move_line_id": move_line.id}, + ) + # Check the picking data + self.assertFalse(move_line.exists()) + moves_product_b = self.picking.move_lines.filtered( + lambda m: m.product_id == self.product_b + ) + self.assertEqual(len(moves_product_b), 2) + move_product_b = moves_product_b.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(len(move_product_b), 1) + self.assertEqual(move_product_b.state, "assigned") + self.assertEqual(len(move_product_b.move_line_ids), 1) + # Check the inventories + stock_issue_inventory = self.env["stock.inventory"].search( + [ + ("line_ids.location_id", "=", self.content_loc.id), + ("line_ids.product_id", "=", self.product_b.id), + ("state", "=", "done"), + ] + ) + self.assertTrue(stock_issue_inventory) + stock_issue_inventory_line = stock_issue_inventory.line_ids.filtered( + lambda l: l.product_id == self.product_b + ) + # 0/4 remaining in the move line's source location + self.assertEqual(stock_issue_inventory_line.product_qty, 0) + # 6/10 remaining elsewhere in the stock + self.assertEqual(self.product_b.qty_available, 6) + control_inventory = self.env["stock.inventory"].search( + [ + ("location_ids", "in", self.content_loc.id), + ("product_ids", "in", self.product_b.id), + ("state", "in", ("draft", "confirm")), + ] + ) + self.assertTrue(control_inventory) + # Check the response + move_lines = self.service._find_transfer_move_lines(self.content_loc) + self.assert_response_start_single( + response, move_lines.mapped("picking_id"), + ) From 0fcdb887ee359a7ba4561aff6d21bbd02d314191 Mon Sep 17 00:00:00 2001 From: sebalix Date: Tue, 7 Jul 2020 15:29:25 +0200 Subject: [PATCH 16/21] location transfer: handle 'shopfloor_picking_sequence' sort key --- shopfloor/actions/location_content_transfer_sorter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shopfloor/actions/location_content_transfer_sorter.py b/shopfloor/actions/location_content_transfer_sorter.py index 80ca0488fe..154dedb281 100644 --- a/shopfloor/actions/location_content_transfer_sorter.py +++ b/shopfloor/actions/location_content_transfer_sorter.py @@ -34,6 +34,8 @@ def _sort_key(content): return ( # postponed content after other contents int(content.shopfloor_postponed), + # sort by shopfloor picking sequence + content.location_dest_id.shopfloor_picking_sequence, # sort by similar destination content.location_dest_id.complete_name, # lines before packages (if we have raw products and packages, raw From e4b1a9708870e7ce210f275cc93624543bae475d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 13 Jul 2020 12:02:44 +0200 Subject: [PATCH 17/21] location transfer: improve pkg level parser --- shopfloor/actions/data.py | 28 +++++++------ .../services/location_content_transfer.py | 1 - shopfloor/services/schema.py | 40 +++++++------------ shopfloor/tests/test_actions_data.py | 28 +++++++++++++ .../test_location_content_transfer_start.py | 4 +- 5 files changed, 60 insertions(+), 41 deletions(-) diff --git a/shopfloor/actions/data.py b/shopfloor/actions/data.py index c2efca8ba6..09ead326eb 100644 --- a/shopfloor/actions/data.py +++ b/shopfloor/actions/data.py @@ -149,17 +149,7 @@ def _move_line_parser(self): ] def package_level(self, record, **kw): - data = self._jsonify(record, self._package_level_parser) - if data: - data.update( - { - # cannot use sub-parser here - # because location_id of the package level may be - # empty, we have to go get the picking's one - "location_src": self.location(record.picking_id.location_id, **kw), - } - ) - return data + return self._jsonify(record, self._package_level_parser) def package_levels(self, records, **kw): return [self.package_level(rec, **kw) for rec in records] @@ -169,8 +159,22 @@ def _package_level_parser(self): return [ "id", "is_done", - ("package_id:package", self._package_parser), + ("package_id:package_src", self._package_parser), ("location_dest_id:location_dest", self._location_parser), + ( + "location_id:location_src", + lambda rec, fname: self.location(rec.picking_id.location_id), + ), + # tnx to stock_quant_package_product_packaging + ( + "package_id:product", + lambda rec, fname: self.product(rec.package_id.single_product_id), + ), + # TODO: allow to pass mapped path to base_jsonify + ( + "package_id:quantity", + lambda rec, fname: rec.package_id.single_product_qty, + ), ] def product(self, record, **kw): diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index bc48d5a891..51572b345e 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -878,7 +878,6 @@ def _schema_single(self): schema_move_line = self.schemas.move_line() return { # we'll have one or the other... - # TODO add the package in the package_level "package_level": self.schemas._schema_dict_of(schema_package_level), "move_line": self.schemas._schema_dict_of(schema_move_line), } diff --git a/shopfloor/services/schema.py b/shopfloor/services/schema.py index c019d057e5..c7d78c39e3 100644 --- a/shopfloor/services/schema.py +++ b/shopfloor/services/schema.py @@ -76,35 +76,21 @@ def move_line(self, with_packaging=False): "id": {"type": "integer", "required": True}, "qty_done": {"type": "float", "required": True}, "quantity": {"type": "float", "required": True}, - "product": {"type": "dict", "required": True, "schema": self.product()}, + "product": self._schema_dict_of(self.product()), "lot": { "type": "dict", "required": False, "nullable": True, "schema": self.lot(), }, - "package_src": { - "type": "dict", - "required": True, - "nullable": True, - "schema": self.package(with_packaging=with_packaging), - }, - "package_dest": { - "type": "dict", - "required": False, - "nullable": True, - "schema": self.package(with_packaging=with_packaging), - }, - "location_src": { - "type": "dict", - "required": True, - "schema": self.location(), - }, - "location_dest": { - "type": "dict", - "required": True, - "schema": self.location(), - }, + "package_src": self._schema_dict_of( + self.package(with_packaging=with_packaging) + ), + "package_dest": self._schema_dict_of( + self.package(with_packaging=with_packaging), required=False + ), + "location_src": self._schema_dict_of(self.location()), + "location_dest": self._schema_dict_of(self.location()), } def product(self): @@ -171,7 +157,9 @@ def package_level(self): return { "id": {"required": True, "type": "integer"}, "is_done": {"type": "boolean", "nullable": False, "required": True}, - "package": {"type": "dict", "schema": self.package()}, - "location_src": {"type": "dict", "schema": self.location()}, - "location_dest": {"type": "dict", "schema": self.location()}, + "package_src": self._schema_dict_of(self.package()), + "location_src": self._schema_dict_of(self.location()), + "location_dest": self._schema_dict_of(self.location()), + "product": self._schema_dict_of(self.product()), + "quantity": {"type": "float", "required": True}, } diff --git a/shopfloor/tests/test_actions_data.py b/shopfloor/tests/test_actions_data.py index e266525036..4be811ac45 100644 --- a/shopfloor/tests/test_actions_data.py +++ b/shopfloor/tests/test_actions_data.py @@ -88,6 +88,15 @@ def _expected_packaging(self, record, **kw): data.update(kw) return data + def _expected_package(self, record, **kw): + data = { + "id": record.id, + "name": record.name, + "weight": record.pack_weight, + } + data.update(kw) + return data + class ActionsDataCase(ActionsDataCaseBase): def test_data_packaging(self): @@ -133,6 +142,25 @@ def test_data_package(self): } self.assertDictEqual(data, expected) + def test_data_package_level(self): + package_level = self.picking.package_level_ids[0] + data = self.data.package_level(package_level) + self.assert_schema(self.schema.package_level(), data) + expected = { + "id": package_level.id, + "is_done": False, + "package_src": self._expected_package(package_level.package_id), + "location_dest": self._expected_location(package_level.location_dest_id), + "location_src": self._expected_location( + package_level.picking_id.location_id + ), + "product": self._expected_product( + package_level.package_id.single_product_id + ), + "quantity": package_level.package_id.single_product_qty, + } + self.assertDictEqual(data, expected) + def test_data_picking(self): self.picking.write({"origin": "created by test", "note": "read me"}) data = self.data.picking(self.picking) diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py index 35b01f465d..66786e1023 100644 --- a/shopfloor/tests/test_location_content_transfer_start.py +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -205,9 +205,9 @@ def test_scan_location_create_moves(self): package_levels = response["data"]["scan_destination_all"]["package_levels"] self.assertIn(move_line_id, picking.move_line_ids.ids) self.assertEqual(package_levels[0]["id"], picking.package_level_ids[0].id) - self.assertEqual(package_levels[0]["package"]["id"], package.id) + self.assertEqual(package_levels[0]["package_src"]["id"], package.id) self.assertEqual(package_levels[1]["id"], picking.package_level_ids[1].id) - self.assertEqual(package_levels[1]["package"]["id"], package2.id) + self.assertEqual(package_levels[1]["package_src"]["id"], package2.id) # product_a in a move line without package self.assertEqual( picking.move_line_ids_without_package.mapped("product_id"), self.product_a From 0ff4c4bcca667701436b4dbf30edae0b9a1341df Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:24:23 +0200 Subject: [PATCH 18/21] location transfer bknd: add msg for completed item --- shopfloor/actions/message.py | 6 +++ .../services/location_content_transfer.py | 14 ++++++- ...ransfer_set_destination_package_or_line.py | 39 +++++++++++++------ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index c497b46924..300a1af679 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -299,6 +299,12 @@ def transfer_complete(self, picking): "body": _("Transfer {} complete").format(picking.name), } + def location_content_transfer_item_complete(self, location_dest): + return { + "message_type": "success", + "body": _("Content transfer to {} completed").format(location_dest.name), + } + def location_content_transfer_complete(self, location): return { "message_type": "success", diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 51572b345e..cb0d9044f2 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -564,7 +564,12 @@ def set_destination_package( package_level.location_dest_id = scanned_location package_moves.with_context(_sf_no_backorder=True)._action_done() move_lines = self._find_transfer_move_lines(location) - return self._response_for_start_single(move_lines.mapped("picking_id")) + message = self.msg_store.location_content_transfer_item_complete( + scanned_location + ) + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=message + ) def set_destination_line( self, location_id, move_line_id, quantity, barcode, confirmation=False @@ -629,7 +634,12 @@ def set_destination_line( move_line.location_dest_id = scanned_location move_line.move_id.with_context(_sf_no_backorder=True)._action_done() move_lines = self._find_transfer_move_lines(location) - return self._response_for_start_single(move_lines.mapped("picking_id")) + message = self.msg_store.location_content_transfer_item_complete( + scanned_location + ) + return self._response_for_start_single( + move_lines.mapped("picking_id"), message=message + ) def postpone_package(self, location_id, package_level_id): """Mark a package level as postponed and return the next level/line diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py index 074c8d20cc..362fdf1973 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -137,7 +137,11 @@ def test_set_destination_package_dest_location_ok(self): ) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), ) for move in package_level.move_line_ids.mapped("move_id"): self.assertEqual(move.state, "done") @@ -235,7 +239,11 @@ def test_set_destination_line_dest_location_ok(self): ) move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), ) self.assertEqual(move_line.move_id.state, "done") self.assertEqual(move_line.picking_id.state, "assigned") @@ -276,7 +284,11 @@ def test_set_destination_line_partial_qty(self): # Check the response move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), ) self.assertEqual(move_line_c.move_id.state, "done") # Scan remaining qty (4/10) @@ -421,15 +433,12 @@ def test_set_destination_package_split_move(self): # Check the response move_lines = self.service._find_transfer_move_lines(self.content_loc) self.assert_response_start_single( - response, move_lines.mapped("picking_id"), + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), ) - # self.assert_response_start( - # response, - # # FIXME: message needs to be refactored to avoid "False" as location name - # message=self.service.msg_store.location_content_transfer_complete( - # self.env["stock.location"] - # ), - # ) def test_set_destination_line_split_move(self): """Scanned destination location valid for a move line, but related moves @@ -477,7 +486,13 @@ def test_set_destination_line_split_move(self): self.assertEqual(remaining_move.move_line_ids.qty_done, 4) # Check the response move_lines = self.service._find_transfer_move_lines(self.content_loc) - self.assert_response_start_single(response, move_lines.mapped("picking_id")) + self.assert_response_start_single( + response, + move_lines.mapped("picking_id"), + message=self.service.msg_store.location_content_transfer_item_complete( + self.dest_location + ), + ) # Process the other move lines (lines w/o package + package levels) # to check the picking state remaining_move_lines = self.picking.move_line_ids_without_package.filtered( From b1160a91d945535844392bc3a2e404d5eceac3f5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:26:29 +0200 Subject: [PATCH 19/21] location transfer bknd: always include source loc in data --- shopfloor/services/location_content_transfer.py | 4 ++++ shopfloor/tests/test_location_content_transfer_base.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index cb0d9044f2..fd35f7905f 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -125,7 +125,10 @@ def _data_content_all_for_location(self, pickings): sorter.feed_pickings(pickings) lines = sorter.move_lines() package_levels = sorter.package_levels() + location = pickings.mapped("move_line_ids.location_id") + assert len(location) == 1, "There should be only one src location at this stage" return { + "location": self.data.location(location), "move_lines": self.data.move_lines(lines), "package_levels": self.data.package_levels(package_levels), } @@ -876,6 +879,7 @@ def _schema_all(self): package_level_schema = self.schemas.package_level() move_line_schema = self.schemas.move_line() return { + "location": self.schemas._schema_dict_of(self.schemas.location()), # we'll display all the packages and move lines *without package # levels* "package_levels": self.schemas._schema_list_of(package_level_schema), diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index 33010fe966..989f867b17 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -57,12 +57,14 @@ def _assert_response_scan_destination_all( # data methods have their own tests lines = pickings.move_line_ids.filtered(lambda line: not line.package_level_id) package_levels = pickings.package_level_ids + location = lines.mapped("location_id") self.assert_response( response, next_state=state, data={ "move_lines": self.data.move_lines(lines), "package_levels": self.data.package_levels(package_levels), + "location": self.data.location(location), }, message=message, ) From f0b066c14fa435b6cc4e604f3fe1dc0206b61ce1 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:27:23 +0200 Subject: [PATCH 20/21] location transfer bknd: rollback move lines and fail if not assigned --- shopfloor/services/location_content_transfer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index fd35f7905f..9601395410 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -292,6 +292,14 @@ def scan_location(self, barcode): new_moves = self._create_moves_from_location(location) new_moves._action_confirm(merge=False) new_moves._action_assign() + if not all([x.state == "assigned" for x in new_moves]): + new_moves._action_cancel() + return self._response_for_start( + message={ + "message_type": "error", + "body": _("New move lines cannot be assigned: canceled."), + } + ) pickings = new_moves.mapped("picking_id") move_lines = new_moves.move_line_ids From 4956baf2b98f43bb4bccd33fe1616ca7b7b96ae6 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 14 Jul 2020 12:36:24 +0200 Subject: [PATCH 21/21] location transfer bknd: get rid of 'confirm_*' states --- .../services/location_content_transfer.py | 93 ++++++++----------- .../test_location_content_transfer_base.py | 42 ++++----- ...on_content_transfer_set_destination_all.py | 7 +- ...ransfer_set_destination_package_or_line.py | 14 ++- 4 files changed, 76 insertions(+), 80 deletions(-) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 9601395410..c2869d7692 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -51,29 +51,23 @@ def _response_for_start(self, message=None): """Transition to the 'start' state""" return self._response(next_state="start", message=message) - def _response_for_scan_destination_all(self, pickings, message=None): + def _response_for_scan_destination_all( + self, pickings, message=None, confirmation_required=False + ): """Transition to the 'scan_destination_all' state The client screen shows a summary of all the lines and packages to move to a single destination. - """ - return self._response( - next_state="scan_destination_all", - data=self._data_content_all_for_location(pickings=pickings), - message=message, - ) - - def _response_for_confirm_scan_destination_all(self, pickings, message=None): - """Transition to the 'confirm_scan_destination_all' state - The client screen shows a summary of all the lines and packages - to move to a single destination. The user has to scan the destination - location a second time to validate the destination. + If `confirmation_required` is set, + the client will ask to scan again the destination """ + data = self._data_content_all_for_location(pickings=pickings) + data["confirmation_required"] = confirmation_required + if confirmation_required and not message: + message = self.msg_store.need_confirmation() return self._response( - next_state="confirm_scan_destination_all", - data=self._data_content_all_for_location(pickings=pickings), - message=message, + next_state="scan_destination_all", data=data, message=message, ) def _response_for_start_single(self, pickings, message=None): @@ -94,30 +88,19 @@ def _response_for_start_single(self, pickings, message=None): message=message, ) - def _response_for_scan_destination(self, location, next_content, message=None): + def _response_for_scan_destination( + self, location, next_content, message=None, confirmation_required=False + ): """Transition to the 'scan_destination' state The client screen shows details of the package level or move line to move. """ + data = self._data_content_line_for_location(location, next_content) + data["confirmation_required"] = confirmation_required + if confirmation_required and not message: + message = self.msg_store.need_confirmation() return self._response( - next_state="scan_destination", - data=self._data_content_line_for_location(location, next_content), - message=message, - ) - - def _response_for_confirm_scan_destination( - self, location, next_content, message=None - ): - """Transition to the 'confirm_scan_destination' state - - The client screen shows details of the package level or move line to - move. The user has to scan the destination location a second time to - validate the destination. - """ - return self._response( - next_state="confirm_scan_destination", - data=self._data_content_line_for_location(location, next_content), - message=message, + next_state="scan_destination", data=data, message=message, ) def _data_content_all_for_location(self, pickings): @@ -381,7 +364,9 @@ def set_destination_all(self, location_id, barcode, confirmation=False): ): # the scanned location is valid (child of picking type's destination) # but not the expected one: ask for confirmation - return self._response_for_confirm_scan_destination_all(pickings) + return self._response_for_scan_destination_all( + pickings, confirmation_required=True + ) self._set_destination_lines(pickings, move_lines, scanned_location) @@ -562,8 +547,8 @@ def set_destination_package( ) if not scanned_location.is_sublocation_of(package_level.location_dest_id): if not confirmation: - return self._response_for_confirm_scan_destination( - location, package_level + return self._response_for_scan_destination( + location, package_level, confirmation_required=True ) package_move_lines = package_level.move_line_ids package_moves = package_move_lines.mapped("move_id") @@ -626,7 +611,9 @@ def set_destination_line( ) if not scanned_location.is_sublocation_of(move_line.location_dest_id): if not confirmation: - return self._response_for_confirm_scan_destination(location, move_line) + return self._response_for_scan_destination( + location, move_line, confirmation_required=True + ) if quantity < move_line.product_uom_qty: # Update the current move line quantity and # put the scanned qty (the move line) in its own move @@ -876,10 +863,8 @@ def _states(self): return { "start": {}, "scan_destination_all": self._schema_all, - "confirm_scan_destination_all": self._schema_all, "start_single": self._schema_single, "scan_destination": self._schema_single, - "confirm_scan_destination": self._schema_single, } @property @@ -892,6 +877,11 @@ def _schema_all(self): # levels* "package_levels": self.schemas._schema_list_of(package_level_schema), "move_lines": self.schemas._schema_list_of(move_line_schema), + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, } @property @@ -902,6 +892,11 @@ def _schema_single(self): # we'll have one or the other... "package_level": self.schemas._schema_dict_of(schema_package_level), "move_line": self.schemas._schema_dict_of(schema_move_line), + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, } def start_or_recover(self): @@ -915,13 +910,7 @@ def scan_location(self): ) def set_destination_all(self): - return self._response_schema( - next_states={ - "start", - "scan_destination_all", - "confirm_scan_destination_all", - } - ) + return self._response_schema(next_states={"start", "scan_destination_all"}) def go_to_single(self): return self._response_schema(next_states={"start", "start_single"}) @@ -937,14 +926,10 @@ def scan_line(self): ) def set_destination_package(self): - return self._response_schema( - next_states={"start_single", "scan_destination", "confirm_scan_destination"} - ) + return self._response_schema(next_states={"start_single", "scan_destination"}) def set_destination_line(self): - return self._response_schema( - next_states={"start_single", "scan_destination", "confirm_scan_destination"} - ) + return self._response_schema(next_states={"start_single", "scan_destination"}) def postpone_package(self): return self._response_schema(next_states={"start_single"}) diff --git a/shopfloor/tests/test_location_content_transfer_base.py b/shopfloor/tests/test_location_content_transfer_base.py index 989f867b17..0bf62b4ef7 100644 --- a/shopfloor/tests/test_location_content_transfer_base.py +++ b/shopfloor/tests/test_location_content_transfer_base.py @@ -50,7 +50,7 @@ def assert_response_start(self, response, message=None): self.assert_response(response, next_state="start", message=message) def _assert_response_scan_destination_all( - self, state, response, pickings, message=None + self, state, response, pickings, message=None, confirmation_required=False ): # this code is repeated from the implementation, not great, but we # mostly want to ensure the selection of pickings is right, and the @@ -65,20 +65,20 @@ def _assert_response_scan_destination_all( "move_lines": self.data.move_lines(lines), "package_levels": self.data.package_levels(package_levels), "location": self.data.location(location), + "confirmation_required": confirmation_required, }, message=message, ) - def assert_response_scan_destination_all(self, response, pickings, message=None): - self._assert_response_scan_destination_all( - "scan_destination_all", response, pickings, message=message - ) - - def assert_response_confirm_scan_destination_all( - self, response, pickings, message=None + def assert_response_scan_destination_all( + self, response, pickings, message=None, confirmation_required=False ): self._assert_response_scan_destination_all( - "confirm_scan_destination_all", response, pickings, message=message + "scan_destination_all", + response, + pickings, + message=message, + confirmation_required=confirmation_required, ) def assert_response_start_single(self, response, pickings, message=None): @@ -93,24 +93,22 @@ def assert_response_start_single(self, response, pickings, message=None): ) def _assert_response_scan_destination( - self, state, response, next_content, message=None + self, state, response, next_content, message=None, confirmation_required=False ): location = next_content.location_id + data = self.service._data_content_line_for_location(location, next_content) + data["confirmation_required"] = confirmation_required self.assert_response( - response, - next_state=state, - data=self.service._data_content_line_for_location(location, next_content), - message=message, - ) - - def assert_response_scan_destination(self, response, next_content, message=None): - self._assert_response_scan_destination( - "scan_destination", response, next_content, message=message + response, next_state=state, data=data, message=message, ) - def assert_response_confirm_scan_destination( - self, response, next_content, message=None + def assert_response_scan_destination( + self, response, next_content, message=None, confirmation_required=False ): self._assert_response_scan_destination( - "confirm_scan_destination", response, next_content, message=message + "scan_destination", + response, + next_content, + message=message, + confirmation_required=confirmation_required, ) diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_all.py b/shopfloor/tests/test_location_content_transfer_set_destination_all.py index c40f566250..35903b2dbf 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_all.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_all.py @@ -104,7 +104,12 @@ def test_set_destination_all_dest_location_need_confirm(self): "barcode": self.shelf2.barcode, }, ) - self.assert_response_confirm_scan_destination_all(response, self.pickings) + self.assert_response_scan_destination_all( + response, + self.pickings, + message=self.service.msg_store.need_confirmation(), + confirmation_required=True, + ) def test_set_destination_all_dest_location_confirmation(self): """Scanned dest. location != child but in picking type location: confirm diff --git a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py index 362fdf1973..b46855f270 100644 --- a/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +++ b/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py @@ -120,8 +120,11 @@ def test_set_destination_package_dest_location_to_confirm(self): "barcode": self.env.ref("stock.stock_location_14").barcode, }, ) - self.assert_response_confirm_scan_destination( - response, package_level, + self.assert_response_scan_destination( + response, + package_level, + message=self.service.msg_store.need_confirmation(), + confirmation_required=True, ) def test_set_destination_package_dest_location_ok(self): @@ -223,7 +226,12 @@ def test_set_destination_line_dest_location_to_confirm(self): "barcode": self.env.ref("stock.stock_location_14").barcode, }, ) - self.assert_response_confirm_scan_destination(response, move_line) + self.assert_response_scan_destination( + response, + move_line, + message=self.service.msg_store.need_confirmation(), + confirmation_required=True, + ) def test_set_destination_line_dest_location_ok(self): """Scanned destination location valid, moves set to done."""