diff --git a/shopfloor/data/shopfloor_scenario_data.xml b/shopfloor/data/shopfloor_scenario_data.xml index 41768a3db3d..9d942916a73 100644 --- a/shopfloor/data/shopfloor_scenario_data.xml +++ b/shopfloor/data/shopfloor_scenario_data.xml @@ -33,7 +33,8 @@ "unload_package_at_destination": true, "multiple_move_single_pack": true, "no_prefill_qty": true, - "scan_location_or_pack_first": true + "scan_location_or_pack_first": true, + "pick_by_product": true } diff --git a/shopfloor/docs/cluster_picking_diag_seq.plantuml b/shopfloor/docs/cluster_picking_diag_seq.plantuml index 71763b2b45b..b7a9c6f6d31 100644 --- a/shopfloor/docs/cluster_picking_diag_seq.plantuml +++ b/shopfloor/docs/cluster_picking_diag_seq.plantuml @@ -1,7 +1,7 @@ # Diagram to generate with PlantUML (https://plantuml.com/) # # $ sudo apt install plantuml -# $ plantuml cluster_picking_diag_seq.plantuml +# $ PLANTUML_LIMIT_SIZE=8192 plantuml cluster_picking_diag_seq.plantuml # @startuml @@ -9,6 +9,9 @@ participant start participant manual_selection participant confirm_start +participant start_product +participant scan_product_destination + participant start_line participant scan_destination participant zero_check @@ -48,12 +51,24 @@ manual_selection -[#red]> manual_selection: **/select(picking_batch_id)** \n(err manual_selection -> confirm_start: **/select(picking_batch_id)** manual_selection -> start: Button **Back** (client-side) +confirm_start -> start_product: **/confirm_start(picking_batch_id)** \n(pick_by_product enabled) confirm_start -> start_line: **/confirm_start(picking_batch_id)** confirm_start -> unload_all: **/confirm_start(picking_batch_id)** \n(we reopen a batch with all lines picked and have to be unloaded in the same destination) confirm_start -> unload_single: **/confirm_start(picking_batch_id)** \n(we reopen a batch with all lines picked and have to be unloaded in different destinations) confirm_start -> start: **/unassign(picking_batch_id)** -== Picking == +== Picking all product quantities (optional) == + +start_product -[#red]> start_product: **/scan_product(picking_batch_id, barcode[product])** \n(error: wrong or unknown barcode) +start_product -> scan_product_destination: **/scan_product(picking_batch_id, barcode[product])** \n(product matches) + +scan_product_destination -[#red]> scan_product_destination: **/scan_product_destination_pack(picking_batch_id, product_id, barcode[package], quantity)** \n(error: bin not found) +scan_product_destination -> start_product: **/scan_product_destination_pack(picking_batch_id, product_id, barcode[package], quantity)** \n(more products to process) +scan_product_destination -> start_line: **/scan_product_destination_pack(picking_batch_id, product_id, barcode[package], quantity)** \n(remaining lines to pick) +scan_product_destination -> unload_all: **/scan_product_destination_pack(picking_batch_id, product_id, barcode[package], quantity)** \n(all lines picked and same destination) +scan_product_destination -> unload_single: **/scan_product_destination_pack(picking_batch_id, product_id, barcode[package], quantity)** \n(all lines picked and different destinations) + +== Picking line by line == start_line -[#red]> start_line: **/scan_line(picking_batch_id, move_line_id, barcode[package|product|lot])** \n(error) start_line -> scan_destination: **/scan_line(picking_batch_id, move_line_id, barcode[package|product|lot])** \n(error) diff --git a/shopfloor/docs/cluster_picking_diag_seq.png b/shopfloor/docs/cluster_picking_diag_seq.png index 64b85bd198d..6cad81d0c59 100644 Binary files a/shopfloor/docs/cluster_picking_diag_seq.png and b/shopfloor/docs/cluster_picking_diag_seq.png differ diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 7521a67db41..b3193ac45e2 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -226,6 +226,22 @@ class ShopfloorMenu(models.Model): allow_alternative_destination_package_is_possible = fields.Boolean( compute="_compute_allow_alternative_destination_package_is_possible" ) + pick_by_product = fields.Boolean( + string="Pick by product", + default=False, + help=( + "Instead of processing move lines one by one, the operator works " + "product by product. When confirmed, the batch shows the first product " + "to pick. After scanning the product and a destination bin, all its " + "lines in the batch are processed at once (qty done and destination " + "bin are set). Continues to the next product until all are completed. " + "Only applies to lines without a source package and not tracked by " + "lot/serial." + ), + ) + pick_by_product_is_possible = fields.Boolean( + compute="_compute_pick_by_product_is_possible" + ) @api.onchange("unload_package_at_destination") def _onchange_unload_package_at_destination(self): @@ -455,3 +471,10 @@ def _compute_allow_alternative_destination_package_is_possible(self): menu.allow_alternative_destination_package_is_possible = ( menu.scenario_id.has_option("allow_alternative_destination_package") ) + + @api.depends("scenario_id") + def _compute_pick_by_product_is_possible(self): + for menu in self: + menu.pick_by_product_is_possible = menu.scenario_id.has_option( + "pick_by_product" + ) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index e37c31a3678..57cc416e8a0 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -105,7 +105,9 @@ def _response_for_start_line( popup=popup, ) - def _response_for_scan_destination(self, move_line, message=None, qty_done=None): + def _response_for_scan_destination( + self, move_line, message=None, qty_done=None, popup=None + ): if qty_done is None: data = self._data_move_line(move_line) else: @@ -120,7 +122,9 @@ def _response_for_scan_destination(self, move_line, message=None, qty_done=None) picking=move_line.picking_id, ) data["disable_full_bin_action"] = self.work.menu.disable_full_bin_action - return self._response(next_state="scan_destination", data=data, message=message) + return self._response( + next_state="scan_destination", data=data, message=message, popup=popup + ) def _response_for_change_pack_lot(self, move_line, message=None): return self._response( @@ -176,6 +180,22 @@ def _response_for_confirm_unload_set_destination( ), ) + def _response_for_start_product(self, batch, product, location, message=None): + return self._response( + next_state="start_product", + data=self._data_for_product_scan(batch, product, location), + message=message, + ) + + def _response_for_scan_product_destination( + self, batch, product, location, message=None + ): + return self._response( + next_state="scan_product_destination", + data=self._data_for_product_scan(batch, product, location), + message=message, + ) + def find_batch(self): """Find a picking batch to work on and start it @@ -321,6 +341,8 @@ def confirm_start(self, picking_batch_id): line to pick. Transitions: + * start_product: when aggregatable products exist and + pick_by_product is enabled * start_line: when the batch has at least one line without destination package * start: if the condition above is wrong (rare case of race condition...) @@ -328,16 +350,97 @@ def confirm_start(self, picking_batch_id): batch = self.env["stock.picking.batch"].browse(picking_batch_id) if not batch.exists(): return self._response_batch_does_not_exist() - return self._pick_next_line(batch) + return self._pick_next_state(batch) + + def scan_product(self, picking_batch_id, barcode): + """Scan the expected product for product-based picking + + Validates the barcode matches the product shown on the start_product + screen. No data is mutated — this is pure validation. + + Transitions: + * scan_product_destination: when the barcode matches the expected product + * start_product: when the barcode does not match + """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + product, location = self._find_next_product_location(batch) + if not product: + return self._response_for_start(message=self.msg_store.record_not_found()) + search = self._actions_for("search") + scanned = search.product_from_scan(barcode) + if not scanned: + return self._response_for_start_product( + batch, + product, + location, + message=self.msg_store.barcode_not_found(), + ) + if scanned != product: + return self._response_for_start_product( + batch, + product, + location, + message=self.msg_store.wrong_record(scanned), + ) + return self._response_for_scan_product_destination(batch, product, location) + + def scan_product_destination_pack( + self, picking_batch_id, product_id, location_id, barcode, quantity + ): + """Set the destination package and qty_done for all lines of a product + + Distributes the total quantity across all aggregatable lines of this + product, sorted by scheduled_date (earliest first). The destination + package is assigned to all affected lines. + + Transitions: + * start_product: when more aggregatable products remain + * start_line: when non-aggregatable lines remain + * unload_all: when all lines are done and have same destination + * unload_single: when all lines are done and have different destinations + """ + batch = self.env["stock.picking.batch"].browse(picking_batch_id) + if not batch.exists(): + return self._response_batch_does_not_exist() + product = self.env["product.product"].browse(product_id) + if not product.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + + lines = self._aggregate_product_location_lines( + product, location, self._aggregatable_lines(batch) + ) + + search = self._actions_for("search") + bin_package = search.package_from_scan(barcode) + if not bin_package: + return self._response_for_scan_product_destination( + batch, + product, + location, + message=self.msg_store.bin_not_found_for_barcode(barcode), + ) + + distributed_qty = self._distribute_qty_by_scheduled_date( + lines, quantity, bin_package + ) - def _pick_next_line(self, batch, message=None, force_line=None): + return self._pick_next_state( + batch, product=product, total_qty=distributed_qty, bin_package=bin_package + ) + + def _pick_next_line(self, batch, message=None, force_line=None, popup=None): if force_line: next_line = force_line else: next_line = self._next_line_for_pick(batch) if not next_line: - return self.prepare_unload(batch.id) - return self._response_for_start_line(next_line, message=message) + return self.prepare_unload(batch.id, message=message) + return self._response_for_start_line(next_line, message=message, popup=popup) @staticmethod def _sort_key_lines(line): @@ -414,6 +517,107 @@ def _data_move_line(self, line, **kw): data.update(kw) return data + def _aggregatable_lines(self, batch): + """Return lines eligible for product-based picking. + + A line is aggregatable if: + - It has no source package (package_id is False) + - Its product is not tracked by lot or serial number + """ + return self._lines_to_pick(batch).filtered( + lambda l: not l.package_id and l.product_id.tracking == "none" + ) + + def _aggregate_product_location_lines(self, product, location, lines): + """Return lines eligible for `product` and `location`.""" + return lines.filtered( + lambda l: l.product_id == product and l.location_id == location + ) + + def _find_next_product_location(self, batch): + """Find the first (product, location) with aggregatable lines, by picking sequence.""" + lines = self._aggregatable_lines(batch) + if not lines: + return self.env["product.product"], self.env["stock.location"] + first_line = fields.first(lines) + return first_line.product_id, first_line.location_id + + def _data_for_product_scan(self, batch, product, location): + """Build response data for product scan states.""" + lines = self._aggregate_product_location_lines( + product, location, self._aggregatable_lines(batch) + ) + total_qty = sum(lines.mapped("product_uom_qty")) + data = { + "product": self.data.product(product), + "quantity": total_qty, + "location_src": self.data.location(location), + "lines": [ + { + "id": line.id, + "picking": self.data.picking(line.picking_id), + "product_uom_qty": line.product_uom_qty, + "product_uom": line.product_uom_id.name, + "qty_done": line.qty_done, + } + for line in lines + ], + "batch": self.data.picking_batch(batch), + } + return data + + def _distribute_qty_by_scheduled_date(self, lines, total_qty, bin_package): + """Distribute total_qty across lines, earliest scheduled_date first. + + The last line may get a partial quantity. + Returns the total quantity actually distributed. + """ + sorted_lines = lines.sorted( + key=lambda l: ( + l.move_id.date, + l.move_id.sequence, + l.move_id.id, + l.id, + ) + ) + remaining = total_qty + for line in sorted_lines: + if remaining <= 0: + break + qty = min(line.product_uom_qty, remaining) + # Do not split the line if qty is less than expected: we want a backorder + # for remaining qty + new_line, qty_check = line._split_qty_to_be_done(qty, split_partial=False) + if qty_check == "greater": + break + line.write({"qty_done": qty, "result_package_id": bin_package.id}) + remaining -= qty + return total_qty - remaining + + def _pick_next_state(self, batch, product=None, total_qty=0, bin_package=None): + """Route to the next state for the batch. + + When product and bin_package are provided, a success message is included. + + Transitions: + * start_product: when aggregatable products remain and + pick_by_product is enabled + * start_line: when non-aggregatable lines remain + * unload_all / unload_single: when all lines are done + """ + message = None + if product and bin_package: + message = self.msg_store.x_units_put_in_package( + total_qty, product, bin_package + ) + if self.work.menu.pick_by_product: + next_product, location = self._find_next_product_location(batch) + if next_product: + return self._response_for_start_product( + batch, next_product, location, message=message + ) + return self._pick_next_line(batch, message=message) + def unassign(self, picking_batch_id): """Unassign and reset to draft a started picking batch @@ -818,7 +1022,7 @@ def _are_all_dest_location_same(self, batch): lines_to_unload = self._lines_to_unload(batch) return len(lines_to_unload.mapped("location_dest_id")) == 1 - def prepare_unload(self, picking_batch_id): + def prepare_unload(self, picking_batch_id, message=None): """Initiate the unloading phase of the scenario It goes to different screens depending if all the move lines have @@ -832,7 +1036,7 @@ def prepare_unload(self, picking_batch_id): if not batch.exists(): return self._response_batch_does_not_exist() if self._are_all_dest_location_same(batch): - return self._response_for_unload_all(batch) + return self._response_for_unload_all(batch, message=message) else: # the lines have different destinations return self._unload_next_package(batch) @@ -1180,8 +1384,8 @@ def _unload_end(self, batch, completion_info_popup=None): next_line = self._next_line_for_pick(batch) if next_line: - return self._response_for_start_line( - next_line, + return self._pick_next_line( + batch, message=self.msg_store.batch_transfer_line_done(), popup=completion_info_popup, ) @@ -1343,6 +1547,26 @@ def confirm_start(self): "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} } + def scan_product(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def scan_product_destination_pack(self): + return { + "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}, + "product_id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "quantity": { + "coerce": to_float, + "required": True, + "nullable": True, + "type": "float", + }, + } + def unassign(self): return { "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"} @@ -1455,6 +1679,8 @@ def _states(self): "unload_set_destination": self._schema_for_unload_single, "confirm_unload_set_destination": self._schema_for_unload_single, "change_pack_lot": self._schema_for_single_line_details, + "start_product": self._schema_for_product_scan, + "scan_product_destination": self._schema_for_product_scan, } def find_batch(self): @@ -1470,6 +1696,7 @@ def confirm_start(self): return self._response_schema( next_states={ "start_line", + "start_product", # we reopen a batch already started where all the lines were # already picked and have to be unloaded to the same # destination @@ -1481,6 +1708,21 @@ def confirm_start(self): } ) + def scan_product(self): + return self._response_schema( + next_states={"start_product", "scan_product_destination"} + ) + + def scan_product_destination_pack(self): + return self._response_schema( + next_states={ + "start_product", + "start_line", + "unload_all", + "unload_single", + } + ) + def unassign(self): return self._response_schema(next_states={"start"}) @@ -1649,3 +1891,27 @@ def _schema_for_scan_destination(self): schema = self._schema_for_single_line_details schema["disable_full_bin_action"] = {"type": "boolean"} return schema + + @property + def _schema_for_product_scan(self): + schema = { + "product": self.schemas._schema_dict_of(self.schemas.product()), + "quantity": {"required": True, "type": "float"}, + "location_src": self.schemas._schema_dict_of(self.schemas.location()), + "lines": { + "type": "list", + "required": True, + "schema": { + "type": "dict", + "schema": { + "id": {"type": "integer", "required": True}, + "picking": self.schemas._schema_dict_of(self.schemas.picking()), + "product_uom_qty": {"type": "float", "required": True}, + "product_uom": {"type": "string", "required": True}, + "qty_done": {"type": "float", "required": True}, + }, + }, + }, + "batch": self.schemas._schema_dict_of(self.schemas.picking_batch()), + } + return schema diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 7f5c27e658d..95f68355a48 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -15,6 +15,7 @@ from . import test_cluster_picking_scan_line_no_prefill_qty from . import test_cluster_picking_scan_destination from . import test_cluster_picking_scan_destination_no_prefill_qty +from . import test_cluster_picking_pick_by_product from . import test_cluster_picking_is_zero from . import test_cluster_picking_skip from . import test_cluster_picking_stock_issue diff --git a/shopfloor/tests/test_cluster_picking_pick_by_product.py b/shopfloor/tests/test_cluster_picking_pick_by_product.py new file mode 100644 index 00000000000..fa9d254a730 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_pick_by_product.py @@ -0,0 +1,403 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .test_cluster_picking_base import ClusterPickingCommonCase + + +class ClusterPickingProductScanCase(ClusterPickingCommonCase): + """Tests for the 'Product scan pick all goods' menu option. + + When enabled, scanning a product (no source package, not tracked by + lot/serial) shows a summary screen and allows batch-processing all + lines of that product at once. + """ + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [cls.BatchProduct(product=cls.product_a, quantity=10)], + [cls.BatchProduct(product=cls.product_a, quantity=20)], + [cls.BatchProduct(product=cls.product_b, quantity=10)], + [cls.BatchProduct(product=cls.product_a, quantity=5)], + ] + ) + cls.menu.sudo().write({"pick_by_product": True}) + cls._simulate_batch_selected(cls.batch) + + def test_confirm_start_goes_to_start_product_when_aggregatable_lines_exist(self): + """After confirming a batch with aggregatable lines, go to start_product.""" + response = self.service.dispatch( + "confirm_start", + params={"picking_batch_id": self.batch.id}, + ) + # First product by picking sequence should be product_a + self.assertEqual(response["next_state"], "start_product") + data = response["data"]["start_product"] + self.assertEqual(data["product"]["id"], self.product_a.id) + self.assertEqual(data["quantity"], 35.0) + self.assertEqual(len(data["lines"]), 3) + + def test_confirm_start_goes_to_start_line_when_no_aggregatable_lines(self): + """Without aggregatable lines, start_line is shown directly.""" + self.menu.sudo().write({"pick_by_product": False}) + batch = self._create_picking_batch( + [[self.BatchProduct(product=self.product_a, quantity=10)]] + ) + self._simulate_batch_selected(batch) + response = self.service.dispatch( + "confirm_start", + params={"picking_batch_id": batch.id}, + ) + self.assertEqual(response["next_state"], "start_line") + + def test_scan_product_correct_barcode(self): + """Scanning the expected product barcode goes to scan_product_destination.""" + self.service.dispatch( + "confirm_start", + params={"picking_batch_id": self.batch.id}, + ) + response = self.service.dispatch( + "scan_product", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.product_a.barcode, + }, + ) + self.assertEqual(response["next_state"], "scan_product_destination") + data = response["data"]["scan_product_destination"] + self.assertEqual(data["product"]["id"], self.product_a.id) + self.assertEqual(data["quantity"], 35.0) + self.assertEqual(len(data["lines"]), 3) + + def test_scan_product_wrong_barcode(self): + """Scanning the wrong barcode stays in start_product with an error.""" + self.service.dispatch( + "confirm_start", + params={"picking_batch_id": self.batch.id}, + ) + response = self.service.dispatch( + "scan_product", + params={ + "picking_batch_id": self.batch.id, + "barcode": "WRONG_BARCODE", + }, + ) + self.assertEqual(response["next_state"], "start_product") + + def test_scan_product_wrong_product_barcode(self): + """Scanning a barcode of another product stays in start_product.""" + self.service.dispatch( + "confirm_start", + params={"picking_batch_id": self.batch.id}, + ) + response = self.service.dispatch( + "scan_product", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.product_b.barcode, + }, + ) + self.assertEqual(response["next_state"], "start_product") + + def test_scan_product_destination_pack_single_product(self): + """Scan destination pack processes all lines of a product.""" + self.service.dispatch( + "confirm_start", + params={"picking_batch_id": self.batch.id}, + ) + self.service.dispatch( + "scan_product", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.product_a.barcode, + }, + ) + bin1 = self.env["stock.quant.package"].create({}) + response = self.service.dispatch( + "scan_product_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "product_id": self.product_a.id, + "location_id": self.stock_location.id, + "barcode": bin1.name, + "quantity": 35.0, + }, + ) + + # All product_a lines should have qty_done and result_package_id + product_a_lines = self.batch.picking_ids.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + for line in product_a_lines: + self.assertEqual(line.result_package_id, bin1) + self.assertEqual(sum(product_a_lines.mapped("qty_done")), 35.0) + + # Next state should be start_product for product_b + self.assertEqual(response["next_state"], "start_product") + + def test_scan_product_destination_pack_full_qty(self): + """Full quantity is distributed across lines, earliest date first.""" + self.service.dispatch( + "confirm_start", + params={"picking_batch_id": self.batch.id}, + ) + self.service.dispatch( + "scan_product", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.product_a.barcode, + }, + ) + bin1 = self.env["stock.quant.package"].create({}) + self.service.dispatch( + "scan_product_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "product_id": self.product_a.id, + "location_id": self.stock_location.id, + "barcode": bin1.name, + "quantity": 35.0, + }, + ) + + # All product_a lines should have full qty done + product_a_lines = self.batch.picking_ids.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ).sorted( + key=lambda l: ( + l.move_id.date, + l.move_id.sequence, + l.move_id.id, + l.id, + ) + ) + self.assertEqual(product_a_lines[0].qty_done, 10.0) + self.assertEqual(product_a_lines[1].qty_done, 20.0) + self.assertEqual(product_a_lines[2].qty_done, 5.0) + + def test_scan_product_destination_pack_partial_qty(self): + """Partial quantity fills first lines fully, last line gets remainder.""" + self.service.dispatch( + "confirm_start", + params={"picking_batch_id": self.batch.id}, + ) + self.service.dispatch( + "scan_product", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.product_a.barcode, + }, + ) + bin1 = self.env["stock.quant.package"].create({}) + self.service.dispatch( + "scan_product_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "product_id": self.product_a.id, + "location_id": self.stock_location.id, + "barcode": bin1.name, + "quantity": 25.0, + }, + ) + + product_a_lines = self.batch.picking_ids.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ).sorted( + key=lambda l: ( + l.move_id.date, + l.move_id.sequence, + l.move_id.id, + l.id, + ) + ) + # First line gets full 10, second gets 15 (remaining), third gets 0 + self.assertEqual(product_a_lines[0].qty_done, 10.0) + self.assertEqual(product_a_lines[1].qty_done, 15.0) + self.assertEqual(product_a_lines[2].qty_done, 0) + + def test_after_all_products_goes_to_unload_all(self): + """After all aggregatable products are done, go to unload_all.""" + self.service.dispatch( + "confirm_start", + params={"picking_batch_id": self.batch.id}, + ) + # Process product_a + self.service.dispatch( + "scan_product", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.product_a.barcode, + }, + ) + bin1 = self.env["stock.quant.package"].create({}) + self.service.dispatch( + "scan_product_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "product_id": self.product_a.id, + "location_id": self.stock_location.id, + "barcode": bin1.name, + "quantity": 35.0, + }, + ) + + # Now process product_b + self.service.dispatch( + "scan_product", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.product_b.barcode, + }, + ) + bin2 = self.env["stock.quant.package"].create({}) + response = self.service.dispatch( + "scan_product_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "product_id": self.product_b.id, + "location_id": self.stock_location.id, + "barcode": bin2.name, + "quantity": 10.0, + }, + ) + + # All aggregatable products done -> prepare_unload + # All lines have a destination package -> unload + self.assertEqual(response["next_state"], "unload_all") + + def test_lot_tracked_line_not_aggregated(self): + """A lot-tracked line is not aggregated.""" + self.menu.sudo().write({"pick_by_product": True}) + product_lot = ( + self.env["product.product"] + .sudo() + .create( + { + "name": "Product Lot", + "type": "product", + "tracking": "lot", + "barcode": "LOT", + "default_code": "LOT", + } + ) + ) + batch = self._create_picking_batch( + [ + [self.BatchProduct(product=product_lot, quantity=10)], + [self.BatchProduct(product=product_lot, quantity=20)], + ] + ) + self._simulate_batch_selected(batch, in_lot=True) + + # Confirm start should go to start_line, not start_product + response = self.service.dispatch( + "confirm_start", + params={"picking_batch_id": batch.id}, + ) + self.assertEqual(response["next_state"], "start_line") + + def test_scan_line_still_works_for_non_aggregatable_lines(self): + """scan_line endpoint works normally for lines not in product scan.""" + product_lot = ( + self.env["product.product"] + .sudo() + .create( + { + "name": "Product Lot", + "type": "product", + "tracking": "lot", + "barcode": "LOT", + "default_code": "LOT", + } + ) + ) + batch = self._create_picking_batch( + [ + [self.BatchProduct(product=product_lot, quantity=10)], + ] + ) + self._simulate_batch_selected(batch, in_lot=True) + self.menu.sudo().write({"pick_by_product": True}) + + response = self.service.dispatch( + "confirm_start", + params={"picking_batch_id": batch.id}, + ) + self.assertEqual(response["next_state"], "start_line") + + line = batch.picking_ids.move_line_ids[0] + response = self.service.dispatch( + "scan_line", + params={ + "picking_batch_id": batch.id, + "move_line_id": line.id, + "barcode": product_lot.barcode, + }, + ) + self.assertEqual(response["next_state"], "scan_destination") + + def test_feature_off_behaves_normally(self): + """With feature disabled, original flow is unchanged.""" + self.menu.sudo().write({"pick_by_product": False}) + batch = self._create_picking_batch( + [ + [self.BatchProduct(product=self.product_a, quantity=10)], + [self.BatchProduct(product=self.product_a, quantity=20)], + ] + ) + self._simulate_batch_selected(batch) + + response = self.service.dispatch( + "confirm_start", + params={"picking_batch_id": batch.id}, + ) + self.assertEqual(response["next_state"], "start_line") + + def test_start_product_shows_expected_data(self): + """start_product shows product info, total qty, and lines.""" + response = self.service.dispatch( + "confirm_start", + params={"picking_batch_id": self.batch.id}, + ) + data = response["data"]["start_product"] + self.assertEqual(data["product"]["id"], self.product_a.id) + self.assertEqual(data["quantity"], 35.0) + self.assertEqual(len(data["lines"]), 3) + for line_data in data["lines"]: + self.assertIn("id", line_data) + self.assertIn("picking", line_data) + self.assertIn("product_uom_qty", line_data) + self.assertIn("qty_done", line_data) + self.assertIn("batch", data) + + def test_next_product_shown_after_scan_complete(self): + """After processing one product, the next aggregatable product is shown.""" + self.service.dispatch( + "confirm_start", + params={"picking_batch_id": self.batch.id}, + ) + self.service.dispatch( + "scan_product", + params={ + "picking_batch_id": self.batch.id, + "barcode": self.product_a.barcode, + }, + ) + bin1 = self.env["stock.quant.package"].create({}) + response = self.service.dispatch( + "scan_product_destination_pack", + params={ + "picking_batch_id": self.batch.id, + "product_id": self.product_a.id, + "location_id": self.stock_location.id, + "barcode": bin1.name, + "quantity": 35.0, + }, + ) + + self.assertEqual(response["next_state"], "start_product") + self.assertEqual( + response["data"]["start_product"]["product"]["id"], self.product_b.id + ) diff --git a/shopfloor/tests/test_cluster_picking_scan_destination.py b/shopfloor/tests/test_cluster_picking_scan_destination.py index c4cf9cb5580..f399896f74a 100644 --- a/shopfloor/tests/test_cluster_picking_scan_destination.py +++ b/shopfloor/tests/test_cluster_picking_scan_destination.py @@ -98,6 +98,9 @@ def test_scan_destination_pack_ok_last_line(self): # they reach the same destination so next state unload_all next_state="unload_all", data=data, + message=self.service.msg_store.x_units_put_in_package( + line.qty_done, line.product_id, self.bin2 + ), ) def test_scan_destination_pack_not_empty_same_picking(self): diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 5617de5f9ea..b753e027eb9 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -135,6 +135,13 @@ + + + + +
+ + + + + + + + {{ $t("cluster_picking.qty_per_transfer") }} +
+
+ {{ line.picking.name }}: + {{ line.product_uom_qty }} {{ line.product_uom }} +
+
+
+
+
+
+ + + + + + + + {{ $t("cluster_picking.qty_per_transfer") }} +
+
+ {{ line.picking.name }}: + {{ line.product_uom_qty }} {{ line.product_uom }} +
+
+
+
+
{ + this.wait_call( + this.odoo.call("scan_product", { + picking_batch_id: this.current_batch().id, + barcode: scanned.text, + }) + ); + }, + }, + scan_product_destination: { + display_info: { + title: this.$t( + "cluster_picking.scan_product_destination.title" + ), + scan_placeholder: this.$t( + "cluster_picking.scan_product_destination.scan_placeholder" + ), + }, + events: { + qty_edit: "on_qty_edit", + }, + enter: () => { + this.scan_destination_qty = this.state.data.quantity; + }, + on_qty_edit: (qty) => { + this.scan_destination_qty = parseInt(qty, 10); + }, + on_scan: (scanned) => { + this.wait_call( + this.odoo.call("scan_product_destination_pack", { + picking_batch_id: this.current_batch().id, + product_id: this.state.data.product.id, + location_id: this.state.data.location_src.id, + barcode: scanned.text, + quantity: this.scan_destination_qty, + }) + ); + }, + }, start_line: { display_info: { title: this.$t("cluster_picking.start_line.title"),