diff --git a/shopfloor/actions/change_package_lot.py b/shopfloor/actions/change_package_lot.py index 18a89aae33..bb56cbafb2 100644 --- a/shopfloor/actions/change_package_lot.py +++ b/shopfloor/actions/change_package_lot.py @@ -161,6 +161,9 @@ def is_lesser(value, other, rounding): # lines move_line.move_id._action_assign() + self._change_pack_lot_change_lot__before_assign( + previous_lot, lot, to_assign_moves + ) # Find other available goods for the lines which were using the # lot before... to_assign_moves._action_assign() @@ -170,6 +173,11 @@ def is_lesser(value, other, rounding): message["body"] = "{} {}".format(message["body"], " ".join(message_parts)) return response_ok_func(move_line, message=message) + def _change_pack_lot_change_lot__before_assign( + self, previous_lot, lot, to_assign_moves + ): + pass + def _package_content_replacement_allowed(self, package, move_line): # we can't replace by a package which doesn't contain the product... return move_line.product_id in package.quant_ids.product_id @@ -215,3 +223,14 @@ def change_package(self, move_line, package, response_ok_func, response_error_fu else: message = self.msg_store.units_replaced_by_package(package) return response_ok_func(move_line, message=message) + + def filter_lines_allowed_to_change_lot(self, move_lines, lot): + """Filter move lines allowed to change their lot. + + We cannot change a lot on a move having ancestors. That would mean we + already picked up the wrong lot on the previous move(s) and Odoo already + restricts the reservation based on the previous move(s). + """ + return move_lines.filtered( + lambda l: (l.product_id == lot.product_id and not l.move_id.move_orig_ids) + ) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 01d9bda072..2e08b9f22e 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -106,6 +106,16 @@ def package_different_change(self): ), } + def lot_different_change(self): + return { + "message_type": "warning", + "body": _( + "You scanned a different lot with the same product, " + "do you want to change lot? Scan it again to confirm. " + "The first line matching this product will be updated. " + ), + } + def package_not_available_in_picking(self, package, picking): return { "message_type": "warning", @@ -526,6 +536,12 @@ def lot_multiple_packages_scan_package(self): "body": _("This lot is part of multiple packages, please scan a package."), } + def lot_not_found(self): + return { + "message_type": "error", + "body": _("This lot does not exist anymore."), + } + def lot_not_found_in_pickings(self): return { "message_type": "warning", @@ -897,3 +913,24 @@ def package_transfer_not_allowed_scan_location(self): "please scan a location instead." ), } + + def lot_changed(self): + return { + "message_type": "info", + "body": _("Lot changed"), + } + + def lot_change_wrong_lot(self, lot_name): + return { + "message_type": "error", + "body": _("Scanned lot differs from the previous scan: %(lot)s.") + % { + "lot": lot_name, + }, + } + + def lot_change_no_line_found(self): + return { + "message_type": "error", + "body": _("Unable to find a line with the same product but different lot."), + } diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py index b69ad5668d..d20a81fbd8 100644 --- a/shopfloor/services/checkout.py +++ b/shopfloor/services/checkout.py @@ -41,24 +41,29 @@ class Checkout(Component): _description = __doc__ def _response_for_select_line( - self, picking, message=None, need_confirm_pack_all=False + self, picking, message=None, need_confirm_pack_all=False, need_confirm_lot=None ): if all(line.shopfloor_checkout_done for line in picking.move_line_ids): return self._response_for_summary(picking, message=message) return self._response( next_state="select_line", data=self._data_for_select_line( - picking, need_confirm_pack_all=need_confirm_pack_all + picking, + need_confirm_pack_all=need_confirm_pack_all, + need_confirm_lot=need_confirm_lot, ), message=message, ) - def _data_for_select_line(self, picking, need_confirm_pack_all=False): + def _data_for_select_line( + self, picking, need_confirm_pack_all=False, need_confirm_lot=None + ): return { "picking": self._data_for_stock_picking(picking), "group_lines_by_location": True, "show_oneline_package_content": self.work.menu.show_oneline_package_content, "need_confirm_pack_all": need_confirm_pack_all, + "need_confirm_lot": need_confirm_lot, } def _response_for_summary(self, picking, need_confirm=False, message=None): @@ -420,7 +425,7 @@ def _deselect_lines(self, lines): {"qty_done": 0, "shopfloor_user_id": False} ) - def scan_line(self, picking_id, barcode, confirm_pack_all=False): + def scan_line(self, picking_id, barcode, confirm_pack_all=False, confirm_lot=None): """Scan move lines of the stock picking It allows to select move lines of the stock picking for the next @@ -451,7 +456,7 @@ def scan_line(self, picking_id, barcode, confirm_pack_all=False): search_result = self._scan_line_find(picking, barcode) result_handler = getattr(self, "_select_lines_from_" + search_result.type) - kw = {"confirm_pack_all": confirm_pack_all} + kw = {"confirm_pack_all": confirm_pack_all, "confirm_lot": confirm_lot} return result_handler(picking, selection_lines, search_result.record, **kw) def _scan_line_find(self, picking, barcode, search_types=None): @@ -503,6 +508,7 @@ def _select_lines_from_package( def _select_lines_from_product( self, picking, selection_lines, product, prefill_qty=1, check_lot=True, **kw ): + # TODO: should we propagate 'kw.get("message")' content on each return? if product.tracking in ("lot", "serial") and check_lot: return self._response_for_select_line( picking, message=self.msg_store.scan_lot_on_product_tracked_by_lot() @@ -532,7 +538,11 @@ def _select_lines_from_product( # Select all the lines of the package when we scan a product in a # package and we have only one. return self._select_lines_from_package( - picking, selection_lines, packages, prefill_qty=prefill_qty + picking, + selection_lines, + packages, + prefill_qty=prefill_qty, + message=kw.get("message"), ) else: # There is no package on selected lines, so also select all other lines @@ -545,7 +555,9 @@ def _select_lines_from_product( lines = self._select_lines( lines, prefill_qty=prefill_qty, related_lines=related_lines ) - return self._response_for_select_package(picking, lines) + return self._response_for_select_package( + picking, lines, message=kw.get("message") + ) def _select_lines_from_packaging(self, picking, selection_lines, packaging, **kw): return self._select_lines_from_product( @@ -555,15 +567,58 @@ def _select_lines_from_packaging(self, picking, selection_lines, packaging, **kw def _select_lines_from_lot( self, picking, selection_lines, lot, prefill_qty=1, **kw ): - lines = selection_lines.filtered(lambda l: l.lot_id == lot) + message = None + lines = self._picking_lines_by_lot(picking, selection_lines, lot) if not lines: - return self._response_for_select_line( - picking, - message={ - "message_type": "error", - "body": _("Lot is not in the current transfer."), - }, + change_package_lot = self._actions_for("change.package.lot") + if not kw.get("confirm_lot"): + lines_same_product = ( + change_package_lot.filter_lines_allowed_to_change_lot( + selection_lines, lot + ) + ) + # If there's at least one product matching we are good to go. + # In any case, only the 1st line matching will be affected. + if lines_same_product: + return self._response_for_select_line( + picking, + message=self.msg_store.lot_different_change(), + need_confirm_lot=lot.id, + ) + return self._response_for_select_line( + picking, + message=self.msg_store.lot_not_found_in_picking(lot, picking), + ) + # Validate the scanned lot against the previous one + if lot.id != kw["confirm_lot"]: + expected_lot = lot.browse(kw["confirm_lot"]).exists() + return self._response_for_select_line( + picking, + message=self.msg_store.lot_change_wrong_lot(expected_lot.name), + ) + # Change lot confirmed + line = fields.first( + selection_lines.filtered( + lambda l: l.product_id == lot.product_id and l.lot_id != lot + ) ) + if not line: + return self._response_for_select_line( + picking, + message=self.msg_store.lot_change_no_line_found(), + ) + response_ok_func = self._change_lot_response_handler_ok + response_error_func = self._change_lot_response_handler_error + message = change_package_lot.change_lot( + line, lot, response_ok_func, response_error_func + ) + if message["message_type"] == "error": + return self._response_for_select_line(picking, message=message) + else: + lines = line + # Some lines have been recreated, refresh the recordset + # to avoid CacheMiss error + selection_lines = self._lines_to_pack(picking) # When lots are as units outside of packages, we can select them for # packing, but if they are in a package, we want the user to scan the packages. @@ -574,6 +629,8 @@ def _select_lines_from_lot( # package, but also if we have one lot as a package and the same lot as # a unit in another line. In both cases, we want the user to scan the # package. + # NOTE: change_pack_lot already checked this, so if we changed the lot + # we are already safe. if packages and len({line.package_id for line in lines}) > 1: return self._response_for_select_line( picking, message=self.msg_store.lot_multiple_packages_scan_package() @@ -582,7 +639,11 @@ def _select_lines_from_lot( # Select all the lines of the package when we scan a lot in a # package and we have only one. return self._select_lines_from_package( - picking, selection_lines, packages, prefill_qty=prefill_qty, **kw + picking, + selection_lines, + packages, + prefill_qty=prefill_qty, + message=message, ) first_allowed_line = fields.first(lines) @@ -592,8 +653,19 @@ def _select_lines_from_lot( first_allowed_line.product_id, prefill_qty=prefill_qty, check_lot=False, + message=message, ) + def _picking_lines_by_lot(self, picking, selection_lines, lot): + """Control filtering of selected lines by given lot.""" + return selection_lines.filtered(lambda l: l.lot_id == lot) + + def _change_lot_response_handler_ok(self, move_line, message=None): + return message + + def _change_lot_response_handler_error(self, move_line, message=None): + return message + def _select_lines_from_serial(self, picking, selection_lines, lot, **kw): # Search for serial number is actually the same as searching for lot (as of v14...) return self._select_lines_from_lot(picking, selection_lines, lot, **kw) @@ -1479,6 +1551,11 @@ def scan_line(self): "nullable": True, "required": False, }, + "confirm_lot": { + "type": "integer", + "nullable": True, + "required": False, + }, } def select_line(self): @@ -1701,6 +1778,7 @@ def _schema_stock_picking_details(self): group_lines_by_location={"type": "boolean"}, show_oneline_package_content={"type": "boolean"}, need_confirm_pack_all={"type": "boolean"}, + need_confirm_lot={"type": "integer", "nullable": True}, ) @property diff --git a/shopfloor/tests/test_checkout_base.py b/shopfloor/tests/test_checkout_base.py index 120e1e71af..c5134ed85b 100644 --- a/shopfloor/tests/test_checkout_base.py +++ b/shopfloor/tests/test_checkout_base.py @@ -54,6 +54,7 @@ def _data_for_select_line(self, picking, **kw): "group_lines_by_location": True, "show_oneline_package_content": False, "need_confirm_pack_all": False, + "need_confirm_lot": None, } data.update(kw) return data diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py index b79efcbf15..181e8195ff 100644 --- a/shopfloor/tests/test_checkout_scan_line.py +++ b/shopfloor/tests/test_checkout_scan_line.py @@ -1,5 +1,7 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _ + from .test_checkout_scan_line_base import CheckoutScanLineCaseBase @@ -132,7 +134,7 @@ def test_scan_line_product_in_one_package_all_package_lines_ok(self): # more than one package, it would be an error. self._test_scan_line_ok(self.product_a.barcode, picking.move_line_ids) - def _test_scan_line_error(self, picking, barcode, message): + def _test_scan_line_error(self, picking, barcode, message, need_confirm_lot=None): """Test errors for /scan_line :param picking: the picking we are currently working with (selected) @@ -145,7 +147,9 @@ def _test_scan_line_error(self, picking, barcode, message): self.assert_response( response, next_state="select_line", - data=self._data_for_select_line(picking), + data=dict( + self._data_for_select_line(picking), need_confirm_lot=need_confirm_lot + ), message=message, ) @@ -247,17 +251,50 @@ def test_scan_line_error_product_not_in_picking(self): }, ) - def test_scan_line_error_lot_not_in_picking(self): + def test_scan_line_error_lot_different_change_success(self): + """Scan the wrong lot while a line with the same product exists.""" picking = self._create_picking(lines=[(self.product_a, 10)]) self._fill_stock_for_moves(picking.move_lines, in_lot=True) picking.action_assign() + previous_lot = picking.move_line_ids.lot_id + # Create a lot that is not registered in the location we are working on + # so a draft inventory for control is generated automatically when the + # lot is changed. lot = self.env["stock.production.lot"].create( {"product_id": self.product_a.id, "company_id": self.env.company.id} ) self._test_scan_line_error( picking, lot.name, - {"message_type": "error", "body": "Lot is not in the current transfer."}, + self.msg_store.lot_different_change(), + need_confirm_lot=lot.id, + ) + # Second scan to confirm the change of lot + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": lot.name, + "confirm_lot": lot.id, + }, + ) + message = self.msg_store.lot_replaced_by_lot(previous_lot, lot) + inventory_message = _("A draft inventory has been created for control.") + message["body"] = f"{message['body']} {inventory_message}" + self.assert_response( + response, + next_state="select_package", + data={ + "selected_move_lines": [ + self._move_line_data(ml) for ml in picking.move_line_ids + ], + "picking": self.service.data.picking(picking), + "packing_info": self.service._data_for_packing_info(picking), + "no_package_enabled": not self.service.options.get( + "checkout__disable_no_package" + ), + }, + message=message, ) def test_scan_line_error_lot_in_two_packages(self): diff --git a/shopfloor_mobile/static/wms/src/scenario/checkout_states.js b/shopfloor_mobile/static/wms/src/scenario/checkout_states.js index a69b9e5b07..f0dc3d9f10 100644 --- a/shopfloor_mobile/static/wms/src/scenario/checkout_states.js +++ b/shopfloor_mobile/static/wms/src/scenario/checkout_states.js @@ -64,6 +64,7 @@ export const checkout_states = function ($instance) { picking_id: $instance.state.data.picking.id, barcode: scanned.text, confirm_pack_all: $instance.state.data.need_confirm_pack_all, + confirm_lot: $instance.state.data.need_confirm_lot, }) ); },