diff --git a/README.md b/README.md
index ad919e6a2cc..95df6d57ef2 100644
--- a/README.md
+++ b/README.md
@@ -29,14 +29,14 @@ addon | version | maintainers | summary
[sale_stock_release_channel_delivery_date](sale_stock_release_channel_delivery_date/) | 16.0.1.1.2 |
| Compute expected date based on available release channels
[sale_stock_release_channel_partner_by_date](sale_stock_release_channel_partner_by_date/) | 16.0.1.1.0 |
| Release channels integration with Sales
[sale_stock_release_channel_partner_by_date_delivery](sale_stock_release_channel_partner_by_date_delivery/) | 16.0.1.1.1 |
| Filters channels on sales based on selected carrier.
-[shopfloor](shopfloor/) | 16.0.2.16.3 |
| manage warehouse operations with barcode scanners
+[shopfloor](shopfloor/) | 16.0.2.17.0 |
| manage warehouse operations with barcode scanners
[shopfloor_base](shopfloor_base/) | 16.0.1.2.1 |
| Core module for creating mobile apps
[shopfloor_batch_automatic_creation](shopfloor_batch_automatic_creation/) | 16.0.1.1.0 |
| Create batch transfers for Cluster Picking
[shopfloor_mobile](shopfloor_mobile/) | 16.0.1.4.1 |
| Mobile frontend for WMS Shopfloor app
[shopfloor_mobile_base](shopfloor_mobile_base/) | 16.0.1.2.0 |
| Mobile frontend for WMS Shopfloor app
[shopfloor_mobile_base_auth_api_key](shopfloor_mobile_base_auth_api_key/) | 16.0.1.0.0 | | Provides authentication via API key to Shopfloor base mobile app
-[shopfloor_reception](shopfloor_reception/) | 16.0.1.6.8 |
| Reception scenario for shopfloor
-[shopfloor_reception_mobile](shopfloor_reception_mobile/) | 16.0.1.1.3 |
| Scenario for receiving products
+[shopfloor_reception](shopfloor_reception/) | 16.0.1.7.0 |
| Reception scenario for shopfloor
+[shopfloor_reception_mobile](shopfloor_reception_mobile/) | 16.0.1.2.0 |
| Scenario for receiving products
[shopfloor_reception_packaging_dimension](shopfloor_reception_packaging_dimension/) | 16.0.1.0.0 |
| Collect Packaging Dimension from the Reception scenario
[shopfloor_reception_packaging_dimension_mobile](shopfloor_reception_packaging_dimension_mobile/) | 16.0.1.0.0 |
| Frontend for the packaging dimension on reception scenario
[shopfloor_reception_refund_return](shopfloor_reception_refund_return/) | 16.0.1.0.0 |
| Mark created return as to refund
diff --git a/shopfloor/README.rst b/shopfloor/README.rst
index 8fba5a6686f..88f562ba053 100644
--- a/shopfloor/README.rst
+++ b/shopfloor/README.rst
@@ -11,7 +11,7 @@ Shopfloor
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
- !! source digest: sha256:22f8e5b0353c5637d5aea9d6f6ae884fd29d5d25e456451b5d76e0926b14b45c
+ !! source digest: sha256:c15d6753dddf755436a53b85c4d516370700a548b898688c65362447fac692a6
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py
index 551ff614db9..767715c1601 100644
--- a/shopfloor/__manifest__.py
+++ b/shopfloor/__manifest__.py
@@ -6,7 +6,7 @@
{
"name": "Shopfloor",
"summary": "manage warehouse operations with barcode scanners",
- "version": "16.0.2.16.3",
+ "version": "16.0.2.17.0",
"development_status": "Beta",
"category": "Inventory",
"website": "https://github.com/OCA/wms",
diff --git a/shopfloor/static/description/index.html b/shopfloor/static/description/index.html
index 6ebeeaeef14..49d39858d15 100644
--- a/shopfloor/static/description/index.html
+++ b/shopfloor/static/description/index.html
@@ -372,7 +372,7 @@
Shopfloor is a barcode scanner application for internal warehouse operations.
diff --git a/shopfloor/tests/test_actions_data_base.py b/shopfloor/tests/test_actions_data_base.py index 49c8e0d3846..57d2328dd79 100644 --- a/shopfloor/tests/test_actions_data_base.py +++ b/shopfloor/tests/test_actions_data_base.py @@ -120,23 +120,7 @@ def _expected_location(self, record, **kw): return data def _expected_product(self, record, **kw): - data = { - "id": record.id, - "name": record.name, - "display_name": record.display_name, - "default_code": record.default_code, - "barcode": record.barcode, - "packaging": [ - self._expected_packaging(x) for x in record.packaging_ids if x.qty - ], - "uom": { - "factor": record.uom_id.factor, - "id": record.uom_id.id, - "name": record.uom_id.name, - "rounding": record.uom_id.rounding, - }, - "supplier_code": self._expected_supplier_code(record), - } + data = self.data._jsonify(record, self.data._product_parser) data.update(kw) return data diff --git a/shopfloor_reception/README.rst b/shopfloor_reception/README.rst index ccd148f33ad..5fcf652f9e2 100644 --- a/shopfloor_reception/README.rst +++ b/shopfloor_reception/README.rst @@ -11,7 +11,7 @@ Shopfloor Reception !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:4a5cae1dea64b85f2f987a26658f70b634462808055727f8e4475218e37783a8 + !! source digest: sha256:cfdee049f8d54795db4bbfe84208e158c79747d001200a55167286239b20231b !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/shopfloor_reception/__init__.py b/shopfloor_reception/__init__.py index b447b57f1b2..1a8abd9f429 100644 --- a/shopfloor_reception/__init__.py +++ b/shopfloor_reception/__init__.py @@ -1,2 +1,2 @@ -from . import services, models +from . import services, models, actions from .hooks import post_init_hook, uninstall_hook diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index fcd3c221c62..26d837f3987 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "16.0.1.6.8", + "version": "16.0.1.7.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", diff --git a/shopfloor_reception/actions/__init__.py b/shopfloor_reception/actions/__init__.py new file mode 100644 index 00000000000..7bb61dcadf7 --- /dev/null +++ b/shopfloor_reception/actions/__init__.py @@ -0,0 +1,3 @@ +from . import data +from . import schema +from . import message diff --git a/shopfloor_reception/actions/data.py b/shopfloor_reception/actions/data.py new file mode 100644 index 00000000000..64b48741424 --- /dev/null +++ b/shopfloor_reception/actions/data.py @@ -0,0 +1,56 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component +from odoo.addons.shopfloor_base.utils import ensure_model + + +class DataAction(Component): + _inherit = "shopfloor.data.action" + + @property + def _product_parser(self): + """ + The jsonifier engine passes (record, field_name) when calling + parser functions. We use *args to capture them. + """ + res = super(DataAction, self)._product_parser + return res + ["use_expiration_date"] + + @property + def _lot_parser_reception(self): + return self._simple_record_parser() + [ + "ref", + ( + "expiration_date", + lambda rec, fname: + # Odoo Datetime fields are stored as naive UTC in the DB. + rec.expiration_date.isoformat() + "+00:00" + if rec.expiration_date + else None, + ), + ] + + @ensure_model("stock.move.line") + def move_line(self, record, with_picking=False, **kw): + data = super().move_line(record, with_picking, **kw) + + lot_data = {} + if lot := kw.get("lot"): + lot_data = self._jsonify(lot, self._lot_parser_reception) + # add expiration_date from scan if not defined on existing lot + if not lot_data.get("expiration_date") and ( + lot_expiration_date := kw.get("lot_expiration_date") + ): + lot_data["expiration_date"] = lot_expiration_date.isoformat() + else: + if lot_name := kw.get("lot_name"): + lot_data["name"] = lot_name + if lot_expiration_date := kw.get("lot_expiration_date"): + lot_data["expiration_date"] = lot_expiration_date.isoformat() + + if lot_data: + data["lot"] = data.get("lot") or {} + data["lot"].update(lot_data) + + return data diff --git a/shopfloor_reception/actions/message.py b/shopfloor_reception/actions/message.py new file mode 100644 index 00000000000..ef381412374 --- /dev/null +++ b/shopfloor_reception/actions/message.py @@ -0,0 +1,41 @@ +# Copyright 2025 ACSONE SA/NV (https://www.acsone.eu) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import logging + +from odoo import _ + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class MessageAction(Component): + _inherit = "shopfloor.message.action" + + def lot_already_exists_different_expiration_date(self, lot, expiration_date): + formatted_lot_expiration_date = self.work.env[ + "ir.qweb.field.date" + ].value_to_html(lot.expiration_date, {}) + formatted_provided_expiration_date = self.work.env[ + "ir.qweb.field.date" + ].value_to_html(expiration_date, {}) + return { + "message_type": "warning", + "body": _( + "This lot already exists with a different expiration date.\n\n" + "Lot: '%(lot_name)s'\nStored expiration date: %(current)s" + "\nProvided expiration date: %(provided)s", + lot_name=lot.name, + current=formatted_lot_expiration_date, + provided=formatted_provided_expiration_date, + ), + } + + def lot_creation_disabled(self, picking_type): + return { + "message_type": "error", + "body": _( + "The operation type '%(picking_type)s' does not allow to create new lots.", + picking_type=picking_type.display_name, + ), + } diff --git a/shopfloor_reception/actions/schema.py b/shopfloor_reception/actions/schema.py new file mode 100644 index 00000000000..6453b43d532 --- /dev/null +++ b/shopfloor_reception/actions/schema.py @@ -0,0 +1,32 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.addons.component.core import Component + + +class ShopfloorSchemaAction(Component): + _inherit = "shopfloor.schema.action" + + def product(self): + res = super().product() + res.update( + { + "use_expiration_date": { + "type": "boolean", + "nullable": True, + "required": False, + }, + } + ) + return res + + def lot(self): + res = super().lot() + + # We need to be able to send lot name and expiration date info + # for "virtual lot" not yet created -> not yet an id + res.update( + { + "id": {"required": False, "type": "integer"}, + } + ) + return res diff --git a/shopfloor_reception/i18n/it.po b/shopfloor_reception/i18n/it.po index bc65cfa65bd..3d1b1054186 100644 --- a/shopfloor_reception/i18n/it.po +++ b/shopfloor_reception/i18n/it.po @@ -61,6 +61,26 @@ msgstr "Movimenti prodotto (riga movimento di magazzino)" msgid "Reception" msgstr "Ricezione" +#. module: shopfloor_reception +#. odoo-python +#: code:addons/shopfloor_reception/actions/message.py:0 +#, python-format +msgid "" +"The operation type '%(picking_type)s' does not allow to create new lots." +msgstr "" + +#. module: shopfloor_reception +#. odoo-python +#: code:addons/shopfloor_reception/actions/message.py:0 +#, python-format +msgid "" +"This lot already exists with a different expiration date.\n" +"\n" +"Lot: '%(lot_name)s'\n" +"Stored expiration date: %(current)s\n" +"Provided expiration date: %(provided)s" +msgstr "" + #~ msgid "Is Shopfloor Created" #~ msgstr "Il reparto รจ crato" diff --git a/shopfloor_reception/i18n/shopfloor_reception.pot b/shopfloor_reception/i18n/shopfloor_reception.pot index 419d7292ed6..a9c79f1f712 100644 --- a/shopfloor_reception/i18n/shopfloor_reception.pot +++ b/shopfloor_reception/i18n/shopfloor_reception.pot @@ -54,3 +54,23 @@ msgstr "" #: model:stock.picking.type,name:shopfloor_reception.picking_type_reception_demo msgid "Reception" msgstr "" + +#. module: shopfloor_reception +#. odoo-python +#: code:addons/shopfloor_reception/actions/message.py:0 +#, python-format +msgid "" +"The operation type '%(picking_type)s' does not allow to create new lots." +msgstr "" + +#. module: shopfloor_reception +#. odoo-python +#: code:addons/shopfloor_reception/actions/message.py:0 +#, python-format +msgid "" +"This lot already exists with a different expiration date.\n" +"\n" +"Lot: '%(lot_name)s'\n" +"Stored expiration date: %(current)s\n" +"Provided expiration date: %(provided)s" +msgstr "" diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index ba9e5ec8f01..c62c06354fc 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -4,7 +4,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from datetime import datetime, time +from datetime import datetime, time, timezone import pytz from decorator import contextmanager @@ -17,6 +17,8 @@ from odoo.addons.shopfloor.actions.search import SearchResult from odoo.addons.shopfloor.utils import to_float +UTC = timezone.utc + class Reception(Component): """ @@ -73,6 +75,11 @@ def _check_picking_processible(self, pickings): states.append("draft") return super()._check_picking_processible(pickings, states=states) + def _move_line_needs_lot(self, move_line): + return ( + move_line.product_id.tracking in ("lot", "serial") and not move_line.lot_id + ) + def _move_line_by_product(self, product): return self.env["stock.move.line"].search( self._domain_move_line_by_product(product) @@ -195,25 +202,6 @@ def _response_for_confirm_new_package( next_state="confirm_new_package", data=data, message=message ) - def _select_document_from_move_lines(self, move_lines, msg_func): - pickings = move_lines.move_id.picking_id - if len(pickings) == 1: - if ( - move_lines.product_id.tracking not in ("lot", "serial") - or move_lines.lot_id - or move_lines.lot_name - ): - return self._response_for_set_quantity(pickings, move_lines) - return self._response_for_set_lot(pickings, move_lines) - elif len(pickings) > 1: - return self._response_for_select_document( - pickings=pickings, - message=self.msg_store.multiple_picks_found_select_manually(), - ) - # If no available picking with the right state has been found, - # return an error - return self._response_for_select_document(message=msg_func()) - def _scan_document__create_return(self, picking, return_type, barcode): stock = self._actions_for("stock") return_picking = stock.create_return_picking(picking, return_type, barcode) @@ -317,30 +305,31 @@ def _scan_line__find_or_create_line(self, picking, move, qty_done=1): return self._scan_line__assign_user(picking, line, qty_done) def _scan_line__recover(self, picking, line, default_qty): - product = line.product_id message = self.msg_store.recovered_previous_session() # Do not restore further than set_destination, because a destination location # might be set by default, and we want the user to be allowed to change it. if line.result_package_id: # Destination package is set, go to set_destination return self._response_for_set_destination(picking, line, message=message) - if product.tracking not in ("lot", "serial") or (line.lot_id or line.lot_name): - # If lot already set, go to set_quantity - rounding = line.product_uom_id.rounding - if float_is_zero(line.qty_done, precision_rounding=rounding): - # If no qty_done, set default qty_done - line.qty_done = default_qty - return self._before_state__set_quantity(picking, line, message=message) - # Otherwise go to select_lot - return self._response_for_set_lot(picking, line, message=message) + + if self._move_line_needs_lot(line): + return self._set_lot(picking, line, message=message, lot_name=line.lot_name) + + # If lot already set, go to set_quantity + rounding = line.product_uom_id.rounding + if float_is_zero(line.qty_done, precision_rounding=rounding): + # If no qty_done, set default qty_done + line.qty_done = default_qty + return self._before_state__set_quantity(picking, line, message=message) def _scan_line__assign_user(self, picking, line, qty_done): - product = line.product_id stock = self._actions_for("stock") stock.mark_move_line_as_picked(line, quantity=qty_done, split=False) - if product.tracking not in ("lot", "serial") or (line.lot_id or line.lot_name): - return self._before_state__set_quantity(picking, line) - return self._response_for_set_lot(picking, line) + + if self._move_line_needs_lot(line): + return self._set_lot(picking, line, lot_name=line.lot_name) + + return self._before_state__set_quantity(picking, line) def _select_line__filter_lines_by_packaging__return(self, lines, packaging): return_line = fields.first( @@ -837,28 +826,32 @@ def _response_for_manual_selection(self): data = {"pickings": self._data_for_stock_pickings(pickings, with_lines=False)} return self._response(next_state="manual_selection", data=data) - def _response_for_set_lot(self, picking, line, message=None): - self._set_lot_from_parse(picking, line) + def _response_for_set_lot(self, picking, line, message=None, **kw): + # Try pre-fill expiration_date for UI + if kw.get("lot_name") and not kw.get("lot_expiration_date") and not message: + lot = self._actions_for("search").lot_from_scan( + kw.get("lot_name"), line.product_id + ) + kw["lot_expiration_date"] = lot.expiration_date + return self._response( next_state="set_lot", data={ - "selected_move_line": self._data_for_move_lines(line), + "selected_move_line": self._data_for_move_lines(line, **kw), "picking": self.data.picking(picking), }, message=message, ) - def _set_lot_from_parse(self, picking, line): - """ - The lot has not been found in move lines before this call. - - Following the picking type configuration, set it: + def _set_lot(self, picking, line, message=None, **kw): + # Bypass "set_lot" screen and send lot info to endpoint directly if + # lot info have been found when parsing + if response := self._set_lot_from_parse(picking, line): + return response + return self._response_for_set_lot(picking, line, message, **kw) - - on lot_id if record is found - - on lot_name if record is not found - - set expiration date if found in parse result - """ - if line.shopfloor_should_create_lot and self.search_result.parse_result: + def _set_lot_from_parse(self, picking, line): + if self.search_result.parse_result: expiration_date = None lot_name = None found = False @@ -875,10 +868,22 @@ def _set_lot_from_parse(self, picking, line): result.type == "expiration_date" and line.product_id.use_expiration_date ): - expiration_date = result.value + expiration_date = datetime.fromisoformat(result.value) if found: - return self.set_lot(picking.id, line.id, lot_name, expiration_date) + return self.set_lot_confirm_action( + picking.id, line.id, lot_name, expiration_date + ) + + # We could have found a lot, but with result type "unknow" + # Put this afterwards to favor multi-attribute barcode parsing + # logic first + if self.search_result.record and isinstance( + self.search_result.record, self.env["stock.lot"].__class__ + ): + return self.set_lot_confirm_action( + picking.id, line.id, lot_name=self.search_result.record.name + ) def _align_display_product_uom_qty(self, line, response): # This method aligns product uom qties on move lines. @@ -1136,8 +1141,63 @@ def _handle_backorder(self, picking, cancel_backorder=False): # Remove user_id on backorder, if any backorders_after.user_id = False - def set_lot( - self, picking_id, selected_line_id, lot_name=None, expiration_date=None + def scan_lot(self, picking_id, selected_line_id, barcode): + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_processible(picking) + if message: + return self._response_for_set_lot(picking, selected_line, message=message) + if not selected_line.exists(): + return self._response_for_set_lot( + picking, selected_line, message=self.msg_store.record_not_found() + ) + + existing_lot = self.env["stock.lot"] + lot_expiration_date = None + + search = self._actions_for("search") + search_result = search.find( + barcode=barcode, + types=["lot", "expiration_date"], + handler_kw={"lot": {"products": selected_line.product_id}}, + ) + + # Look for more info in the barcode + lot_name = barcode + for result in search_result.parse_result: + if result.type == "lot": + lot_name = result.value + elif result.type == "expiration_date": + lot_expiration_date = result.value + + if search_result.type == "lot": + existing_lot = search_result.record + if not existing_lot: + existing_lot = search.lot_from_scan(lot_name, selected_line.product_id) + + message = None + if ( + lot_expiration_date + and existing_lot + and existing_lot.expiration_date != lot_expiration_date + ): + message = self.msg_store.lot_already_exists_different_expiration_date( + existing_lot, lot_expiration_date + ) + + res = self._response_for_set_lot( + picking, + selected_line, + message=message, + lot=existing_lot, + lot_name=lot_name, + lot_expiration_date=lot_expiration_date, + ) + + return res + + def set_lot_confirm_action( + self, picking_id, selected_line_id, lot_name, expiration_date: datetime = None ): """Set lot and its expiration date @@ -1146,74 +1206,100 @@ def set_lot( expiration_date: The expiration_date transitions: - - select_move: User clicked on back - - set_lot: Barcode not found. Ask user to create one from barcode - - set_lot: expiration_date has been set on the selected line - - set_lot: lot_it has been set on the selected line - set_lot: Error: expiration_date is required - set_quantity: User clicked on the confirm button """ picking = self.env["stock.picking"].browse(picking_id) selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_processible(picking) if message: return self._response_for_set_lot(picking, selected_line, message=message) if not selected_line.exists(): - message = self.msg_store.record_not_found() - return self._response_for_set_lot(picking, selected_line, message=message) - search = self._actions_for("search") - if lot_name: - product = selected_line.product_id - lot = search.lot_from_scan(lot_name, products=product) - if not lot: - lot = self.env["stock.lot"].create( - self._create_lot_values(product, lot_name) - ) - selected_line.lot_id = lot.id - selected_line._onchange_lot_id() + return self._response_for_set_lot( + picking, selected_line, message=self.msg_store.record_not_found() + ) + + product = selected_line.product_id + lot = self.search_result.record or self._actions_for("search").lot_from_scan( + lot_name, product + ) + + if product.use_expiration_date and ( + not expiration_date and not lot.expiration_date + ): + return self._response_for_set_lot( + picking, + selected_line, + message=self.msg_store.expiration_date_missing(), + lot_name=lot_name, + ) + + if not lot: + error_response = self._set_lot_confirm_action__handle_new_lot( + picking, selected_line, lot_name, expiration_date + ) + if error_response: + return error_response + lot = self.env.context["lot"] + else: + error_response = self._set_lot_confirm_action__handle_existing_lot( + picking, selected_line, lot, expiration_date + ) + if error_response: + return error_response + + selected_line.lot_id = lot.id + selected_line._onchange_lot_id() + + return self._before_state__set_quantity(picking, selected_line) + + def _set_lot_confirm_action__handle_new_lot( + self, picking, line, lot_name, expiration_date + ): + if not picking.picking_type_id.use_create_lots: + return self._response_for_set_lot( + picking, + line, + message=self.msg_store.lot_creation_disabled(picking.picking_type_id), + lot_name=lot_name, + lot_expiration_date=expiration_date, + ) + lot_vals = self._create_lot_values(line.product_id, lot_name) if expiration_date: - selected_line.write({"expiration_date": expiration_date}) - selected_line.lot_id.write({"expiration_date": expiration_date}) - return self._response_for_set_lot(picking, selected_line) + lot_vals["expiration_date"] = expiration_date.astimezone(UTC).replace( + tzinfo=None + ) + lot = self.env["stock.lot"].create(lot_vals) + # Inject lot into context to propagate it through the call stack without extra queries + self.env.context = {**self.env.context} | {"lot": lot} + + def _set_lot_confirm_action__handle_existing_lot( + self, picking, line, lot, expiration_date + ): + if not expiration_date: + return + elif not lot.expiration_date: + lot.expiration_date = expiration_date.astimezone(UTC).replace(tzinfo=None) + elif lot.expiration_date.astimezone(UTC) != expiration_date.astimezone(UTC): + # Prevent user from overwritting an existing expiration date on an existing lot + return self._response_for_set_lot( + picking, + line, + message=self.msg_store.lot_already_exists_different_expiration_date( + lot, expiration_date + ), + lot_name=lot.name, + lot_expiration_date=expiration_date, + ) def _create_lot_values(self, product, lot_name): return { "name": lot_name, "product_id": product.id, "company_id": self.env.company.id, - "use_expiration_date": product.use_expiration_date, } - def set_lot_confirm_action(self, picking_id, selected_line_id): - picking = self.env["stock.picking"].browse(picking_id) - message = self._check_picking_processible(picking) - selected_line = self.env["stock.move.line"].browse(selected_line_id) - if message: - return self._response_for_set_lot(picking, selected_line, message=message) - checks = [ - self._check_expiry_date, - self._check_lot, - ] - for check in checks: - message = check(selected_line) - if message: - return self._response_for_set_lot( - picking, selected_line, message=message - ) - return self._before_state__set_quantity(picking, selected_line) - - def _check_lot(self, line): - need_lot = line.product_id.tracking == "lot" - if need_lot and not line.lot_id: - return self.msg_store.scan_lot_on_product_tracked_by_lot() - - def _check_expiry_date(self, line): - use_expiration_date = ( - line.product_id.use_expiration_date or line.lot_id.use_expiration_date - ) - if use_expiration_date and not line.expiration_date: - return self.msg_store.expiration_date_missing() - def _set_quantity__get_handlers_by_type(self): return { "product": self._set_quantity__by_product, @@ -1613,7 +1699,22 @@ def manual_select_move(self): "move_id": {"required": True, "type": "integer"}, } - def set_lot(self): + def set_lot_confirm_action(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "lot_name": {"type": "string", "required": True}, + "expiration_date": { + "type": "datetime", + "coerce": datetime.fromisoformat, + }, + } + + def scan_lot(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, "selected_line_id": { @@ -1621,8 +1722,7 @@ def set_lot(self): "type": "integer", "required": True, }, - "lot_name": {"type": "string"}, - "expiration_date": {"type": "string"}, + "barcode": {"type": "string", "required": True}, } def set_quantity(self): @@ -1711,16 +1811,6 @@ def done_action(self): "confirmation": {"type": "boolean"}, } - def set_lot_confirm_action(self): - return { - "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, - "selected_line_id": { - "coerce": to_int, - "type": "integer", - "required": True, - }, - } - class ShopfloorReceptionValidatorResponse(Component): _inherit = "base.shopfloor.validator.response" @@ -1773,8 +1863,11 @@ def _list_stock_pickings_next_states(self): def _scan_line_next_states(self): return {"select_move", "set_lot", "set_quantity", "set_destination"} - def _set_lot_next_states(self): - return {"select_move", "set_lot", "set_quantity"} + def _set_lot_confirm_action_next_states(self): + return {"set_lot", "set_quantity"} + + def _scan_lot_next_states(self): + return {"set_lot"} def _set_quantity_next_states(self): return {"set_quantity", "select_move", "set_destination"} @@ -1791,9 +1884,6 @@ def _select_dest_package_next_states(self): def _done_next_states(self): return {"select_document", "select_move", "confirm_done"} - def _set_lot_confirm_action_next_states(self): - return {"set_lot", "set_quantity"} - def _process_with_existing_pack_next_states(self): return {"set_quantity", "select_dest_package"} @@ -1938,14 +2028,14 @@ def scan_line(self): def manual_select_move(self): return self._response_schema(next_states=self._scan_line_next_states()) - def set_lot(self): - return self._response_schema(next_states=self._set_lot_next_states()) - def set_lot_confirm_action(self): return self._response_schema( next_states=self._set_lot_confirm_action_next_states() ) + def scan_lot(self): + return self._response_schema(next_states=self._scan_lot_next_states()) + def set_quantity(self): return self._response_schema(next_states=self._set_quantity_next_states()) diff --git a/shopfloor_reception/static/description/index.html b/shopfloor_reception/static/description/index.html index 030780eb904..213ce8c7553 100644 --- a/shopfloor_reception/static/description/index.html +++ b/shopfloor_reception/static/description/index.html @@ -372,7 +372,7 @@Shopfloor implementation of the reception scenario. diff --git a/shopfloor_reception/tests/__init__.py b/shopfloor_reception/tests/__init__.py index 7e192132e7e..106133b0a24 100644 --- a/shopfloor_reception/tests/__init__.py +++ b/shopfloor_reception/tests/__init__.py @@ -3,7 +3,6 @@ from . import test_manual_selection from . import test_select_move from . import test_reception_done -from . import test_set_lot from . import test_set_lot_confirm from . import test_set_quantity from . import test_set_quantity_action @@ -14,3 +13,4 @@ from . import test_return_set_quantity from . import test_return_reception_done from . import test_recover +from . import test_scan_lot diff --git a/shopfloor_reception/tests/test_multi_barcode.py b/shopfloor_reception/tests/test_multi_barcode.py index 82c9d40023a..f9a45de31a9 100644 --- a/shopfloor_reception/tests/test_multi_barcode.py +++ b/shopfloor_reception/tests/test_multi_barcode.py @@ -24,6 +24,7 @@ def test_scan_multiple_attribute_barcode(self): """ picking = self._create_picking() lot = self._create_lot() + lot.expiration_date = None selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) @@ -33,7 +34,7 @@ def test_scan_multiple_attribute_barcode(self): BarcodeResult(type="lot", value=lot.name, raw=lot.name), BarcodeResult( type="expiration_date", - value=fields.Date.to_date("2025-04-15"), + value="2025-04-15", raw="250415", ), ] @@ -47,10 +48,11 @@ def test_scan_multiple_attribute_barcode(self): data = self.data.picking(picking) self.assert_response( response, - next_state="set_lot", + next_state="set_quantity", data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, }, ) self.assertEqual( diff --git a/shopfloor_reception/tests/test_scan_lot.py b/shopfloor_reception/tests/test_scan_lot.py new file mode 100644 index 00000000000..e52a35183e5 --- /dev/null +++ b/shopfloor_reception/tests/test_scan_lot.py @@ -0,0 +1,155 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta, timezone +from unittest import mock + +from odoo import fields + +from odoo.addons.shopfloor.actions.barcode_parser import BarcodeResult +from odoo.addons.shopfloor.actions.search import SearchAction, SearchResult + +from .common import CommonCase + +UTC = timezone.utc +GTIN_AI = "01" +LOT_AI = "10" +EXPIRATION_DATE_AI = "17" + + +class TestScanLotName(CommonCase): + @classmethod + def setUpClassBaseData(cls): + res = super().setUpClassBaseData() + cls.product_a.tracking = "lot" + return res + + def test_scan_lot_extract_expiration_date_new_lot(self): + """ + Test that the expiration date can be extracted from barcode scan + (case when the lot does not already exsit in db) + """ + picking = self._create_picking() + lot = self._create_lot() + expiration_date = fields.Datetime.from_string("2022-07-02") + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + + gs1_barcode = ( + f"{GTIN_AI}01234567890128{EXPIRATION_DATE_AI}220702{LOT_AI}{lot.name}" + ) + + with mock.patch.object(SearchAction, "find") as mock_find: + mock_find.return_value = SearchResult( + record=None, + type="None", + parse_result=[ + BarcodeResult(type="unknown", value=gs1_barcode), + BarcodeResult(type="expiration_date", value=expiration_date), + BarcodeResult(type="lot", value=lot.name), + ], + ) + res = self.service.dispatch( + "scan_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": lot.name, + }, + ) + + self.assertEqual( + res["data"]["set_lot"]["selected_move_line"][0]["lot"]["expiration_date"], + expiration_date.isoformat(), + ) + self.assertEqual( + res["data"]["set_lot"]["selected_move_line"][0]["lot"]["name"], lot.name + ) + + def test_scan_lot_extract_expiration_date_existing_lot(self): + """ + When lot already exists, take the expiration date from the existing lot. + + Ensure there is a warning in case of mismatch between expiration date found in + the barcode and the one on the existing lot. + """ + picking = self._create_picking() + + expiration_date = fields.Datetime.from_string("2022-07-02") + lot = self._create_lot(expiration_date=expiration_date) + self.assertEqual(lot.expiration_date, expiration_date) + + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + + gs1_barcode = ( + f"{GTIN_AI}01234567890128{EXPIRATION_DATE_AI}220704{LOT_AI}{lot.name}" + ) + + with mock.patch.object(SearchAction, "find") as mock_find: + mock_find.return_value = SearchResult( + record=lot, + type="lot", + parse_result=[ + BarcodeResult(type="unknown", value=gs1_barcode), + BarcodeResult( + type="expiration_date", + value=expiration_date + timedelta(days=2), + ), + BarcodeResult(type="lot", value=lot.name), + ], + ) + res = self.service.dispatch( + "scan_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": lot.name, + }, + ) + + self.assertEqual( + datetime.fromisoformat( + res["data"]["set_lot"]["selected_move_line"][0]["lot"][ + "expiration_date" + ] + ), + expiration_date.replace(tzinfo=UTC), + ) + self.assertEqual( + res["data"]["set_lot"]["selected_move_line"][0]["lot"]["name"], + lot.name, + ) + self.assertEqual( + res["message"], + self.msg_store.lot_already_exists_different_expiration_date( + lot, lot.expiration_date + timedelta(days=2) + ), + ) + + def test_scan_lot_name_auto_set_lot_on_move_line(self): + """ + If lot exists and lot_name is set on the move line, + auto-fill the lot_id and skip "sel_lot" state. + """ + picking = self._create_picking() + lot = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.lot_name = lot.name + + res = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": lot.name, + }, + ) + + self.assertEqual(res["next_state"], "set_quantity") + self.assertEqual(selected_move_line.lot_id, lot) diff --git a/shopfloor_reception/tests/test_select_move.py b/shopfloor_reception/tests/test_select_move.py index ad0ded79d9e..7eca065d223 100644 --- a/shopfloor_reception/tests/test_select_move.py +++ b/shopfloor_reception/tests/test_select_move.py @@ -129,7 +129,8 @@ def test_scan_product_partial(self): ) self.assertNotEqual( - previous_line.id, response["data"]["set_lot"]["selected_move_line"][0]["id"] + previous_line.id, + response["data"]["set_quantity"]["selected_move_line"][0]["id"], ) def test_scan_packaging(self): @@ -206,8 +207,8 @@ def test_scan_lot_concurrent(self): }, ) self.assertNotEqual( - res_u1["data"]["set_lot"]["selected_move_line"][0]["id"], - res_u2["data"]["set_lot"]["selected_move_line"][0]["id"], + res_u1["data"]["set_quantity"]["selected_move_line"][0]["id"], + res_u2["data"]["set_quantity"]["selected_move_line"][0]["id"], ) def test_scan_not_tracked_product(self): @@ -434,3 +435,52 @@ def test_manual_select_move(self): "selected_move_line": self.data.move_lines(selected_move_line), }, ) + + def test_select_move_next_state_ignores_lot_name(self): + picking = self._create_picking() + + self.product_a.tracking = "lot" + self.product_b.tracking = "lot" + + move_a = picking.move_ids.filtered(lambda m: m.product_id == self.product_a) + move_line_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + move_line_a.lot_id = self._create_lot() + + move_b = picking.move_ids.filtered(lambda m: m.product_id == self.product_b) + move_line_b = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_b + ) + move_line_b.lot_name = "Pre-Configured Lot Name" + self._create_lot( + product_id=self.product_b.id, + name="Pre-Configured Lot Name", + expiration_date="2020-02-02 12:00:00", + ) + + # There is already a lot -> we skip "set_lot" + response_a = self.service.dispatch( + "manual_select_move", + params={"move_id": move_a.id}, + ) + self.assertEqual(response_a.get("next_state"), "set_quantity") + + # There is a lot name but no lot record -> enter "set_lot" + response_b = self.service.dispatch( + "manual_select_move", + params={"move_id": move_b.id}, + ) + self.assertEqual(response_b.get("next_state"), "set_lot") + + # The UI should receive the lot metadata so as to be able to prefill + self.assertEqual( + response_b["data"]["set_lot"]["selected_move_line"][0]["lot"]["name"], + "Pre-Configured Lot Name", + ) + self.assertEqual( + response_b["data"]["set_lot"]["selected_move_line"][0]["lot"][ + "expiration_date" + ], + "2020-02-02T12:00:00", + ) diff --git a/shopfloor_reception/tests/test_set_lot.py b/shopfloor_reception/tests/test_set_lot.py deleted file mode 100644 index c605aa20eb4..00000000000 --- a/shopfloor_reception/tests/test_set_lot.py +++ /dev/null @@ -1,158 +0,0 @@ -# Copyright 2022 Camptocamp SA -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -# pylint: disable=missing-return -from .common import CommonCase - - -class TestSetLot(CommonCase): - @classmethod - def setUpClassBaseData(cls): - super().setUpClassBaseData() - cls.product_a.tracking = "lot" - - def test_set_existing_lot(self): - picking = self._create_picking() - lot = self._create_lot() - selected_move_line = picking.move_line_ids.filtered( - lambda l: l.product_id == self.product_a - ) - selected_move_line.shopfloor_user_id = self.env.uid - response = self.service.dispatch( - "set_lot", - params={ - "picking_id": picking.id, - "selected_line_id": selected_move_line.id, - "lot_name": lot.name, - }, - ) - self.assertEqual(selected_move_line.lot_id, lot) - self.assertFalse(selected_move_line.expiration_date) - data = self.data.picking(picking) - self.assert_response( - response, - next_state="set_lot", - data={ - "picking": data, - "selected_move_line": self.data.move_lines(selected_move_line), - }, - ) - - def test_set_new_lot_on_line_with_lot(self): - picking = self._create_picking() - lot_before = self._create_lot() - selected_move_line = picking.move_line_ids.filtered( - lambda l: l.product_id == self.product_a - ) - selected_move_line.shopfloor_user_id = self.env.uid - selected_move_line.lot_id = lot_before - lot_after = self._create_lot() - response = self.service.dispatch( - "set_lot", - params={ - "picking_id": picking.id, - "selected_line_id": selected_move_line.id, - "lot_name": lot_after.name, - }, - ) - self.assertEqual(selected_move_line.lot_id, lot_after) - self.assertFalse(selected_move_line.expiration_date) - data = self.data.picking(picking) - self.assert_response( - response, - next_state="set_lot", - data={ - "picking": data, - "selected_move_line": self.data.move_lines(selected_move_line), - }, - ) - - def test_set_existing_lot_with_expiration_date(self): - self.product_a.use_expiration_date = True - picking = self._create_picking() - expiration_date = "2022-08-23 12:00:00" - lot = self._create_lot(expiration_date=expiration_date) - selected_move_line = picking.move_line_ids.filtered( - lambda l: l.product_id == self.product_a - ) - selected_move_line.shopfloor_user_id = self.env.uid - response = self.service.dispatch( - "set_lot", - params={ - "picking_id": picking.id, - "selected_line_id": selected_move_line.id, - "lot_name": lot.name, - }, - ) - self.assertEqual(str(selected_move_line.expiration_date), expiration_date) - data = self.data.picking(picking) - self.assert_response( - response, - next_state="set_lot", - data={ - "picking": data, - "selected_move_line": self.data.move_lines(selected_move_line), - }, - ) - - def test_set_new_lot(self): - picking = self._create_picking() - selected_move_line = picking.move_line_ids.filtered( - lambda l: l.product_id == self.product_a - ) - selected_move_line.shopfloor_user_id = self.env.uid - response = self.service.dispatch( - "set_lot", - params={ - "picking_id": picking.id, - "selected_line_id": selected_move_line.id, - "lot_name": "FooBar", - }, - ) - self.assertEqual(selected_move_line.lot_id.name, "FooBar") - data = self.data.picking(picking) - self.assert_response( - response, - next_state="set_lot", - data={ - "picking": data, - "selected_move_line": self.data.move_lines(selected_move_line), - }, - ) - - def test_set_expiry_date(self): - # First, set the lot - picking = self._create_picking() - lot = self._create_lot() - selected_move_line = picking.move_line_ids.filtered( - lambda l: l.product_id == self.product_a - ) - selected_move_line.shopfloor_user_id = self.env.uid - self.service.dispatch( - "set_lot", - params={ - "picking_id": picking.id, - "selected_line_id": selected_move_line.id, - "lot_name": lot.name, - }, - ) - # Then, set the expiration date - expiration_date = "2022-08-24 12:00:00" - response = self.service.dispatch( - "set_lot", - params={ - "picking_id": picking.id, - "selected_line_id": selected_move_line.id, - "expiration_date": expiration_date, - }, - ) - self.assertEqual(str(lot.expiration_date), expiration_date) - self.assertEqual(str(selected_move_line.expiration_date), expiration_date) - data = self.data.picking(picking) - self.assert_response( - response, - next_state="set_lot", - data={ - "picking": data, - "selected_move_line": self.data.move_lines(selected_move_line), - }, - ) diff --git a/shopfloor_reception/tests/test_set_lot_confirm.py b/shopfloor_reception/tests/test_set_lot_confirm.py index 238f4546f66..5a2230eea12 100644 --- a/shopfloor_reception/tests/test_set_lot_confirm.py +++ b/shopfloor_reception/tests/test_set_lot_confirm.py @@ -1,6 +1,8 @@ # Copyright 2022 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # pylint: disable=missing-return +from freezegun import freeze_time + from .common import CommonCase @@ -8,82 +10,270 @@ class TestSetLotConfirm(CommonCase): @classmethod def setUpClassBaseData(cls): super().setUpClassBaseData() + cls.product_a.tracking = "lot" - def test_ensure_lot(self): + def test_set_existing_lot(self): picking = self._create_picking() - self.product_a.tracking = "lot" + lot = self._create_lot() selected_move_line = picking.move_line_ids.filtered( - lambda li: li.product_id == self.product_a + lambda l: l.product_id == self.product_a ) + selected_move_line.shopfloor_user_id = self.env.uid response = self.service.dispatch( "set_lot_confirm_action", params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, + "lot_name": lot.name, }, ) - message = self.msg_store.scan_lot_on_product_tracked_by_lot() + self.assertEqual(selected_move_line.lot_id, lot) + self.assertFalse(selected_move_line.expiration_date) self.assert_response( response, - next_state="set_lot", + next_state="set_quantity", data={ "picking": self.data.picking(picking), "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, }, - message=message, ) - def test_ensure_expiry_date(self): + def test_set_new_lot(self): picking = self._create_picking() + picking.picking_type_id.sudo().use_create_lots = True + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_lot_confirm_action", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": "FooBar", + }, + ) + self.assertEqual(selected_move_line.lot_id.name, "FooBar") + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": self.data.picking(picking), + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + ) + + def test_set_new_lot_on_line_with_lot(self): + picking = self._create_picking() + lot_before = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + selected_move_line.lot_id = lot_before + lot_after = self._create_lot() + response = self.service.dispatch( + "set_lot_confirm_action", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot_after.name, + }, + ) + self.assertEqual(selected_move_line.lot_id, lot_after) + self.assertFalse(selected_move_line.expiration_date) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": self.data.picking(picking), + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + ) + + @freeze_time("2020-01-01 11:00:00") + def test_set_existing_lot_with_expiration_date(self): self.product_a.use_expiration_date = True + picking = self._create_picking() + expiration_date = "2022-08-23 12:00:00" + lot = self._create_lot(expiration_date=expiration_date) selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) selected_move_line.shopfloor_user_id = self.env.uid - # product has been set as requiring a expiration date. - # Trying to move to the next screen should return an error response = self.service.dispatch( "set_lot_confirm_action", params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, + "lot_name": lot.name, }, ) - data = self.data.picking(picking) + self.assertEqual(str(selected_move_line.expiration_date), expiration_date) self.assert_response( response, - next_state="set_lot", + next_state="set_quantity", data={ - "picking": data, + "picking": self.data.picking(picking), "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, }, - message={"message_type": "error", "body": "Missing expiration date."}, ) - # Now, set the expiry date - expiration_date = "2022-08-24 12:00:00" - self.service.dispatch( - "set_lot", + + @freeze_time("2020-01-01 11:00:00") + def test_set_existing_lot_try_overwrite_expiration_date_error(self): + self.product_a.use_expiration_date = True + picking = self._create_picking() + lot_expiration_date = "2022-08-23 12:00:00" + lot = self._create_lot(expiration_date=lot_expiration_date) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + + new_expiration_date = "2022-08-27 12:00:00" + response = self.service.dispatch( + "set_lot_confirm_action", params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "expiration_date": expiration_date, + "lot_name": lot.name, + "expiration_date": new_expiration_date, }, ) - # And try to confirm again + self.assertEqual( + str(lot.expiration_date), + lot_expiration_date, + "Existing lot expiration date should not be overwritten", + ) + + # The error should send back the selected lot name and expiration date so + # to prevent clearing the UI fields after error message + move_line_response_data = self.data.move_lines(selected_move_line) + move_line_response_data[0]["lot"] = { + "name": lot.name, + "expiration_date": new_expiration_date.replace(" ", "T"), + } + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": self.data.picking(picking), + "selected_move_line": move_line_response_data, + }, + message=self.msg_store.lot_already_exists_different_expiration_date( + lot, new_expiration_date + ), + ) + + @freeze_time("2020-01-01 11:00:00") + def test_set_new_lot_and_expiration_date(self): + picking = self._create_picking() + picking.picking_type_id.sudo().use_create_lots = True + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid response = self.service.dispatch( "set_lot_confirm_action", params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, + "lot_name": "FooBar", + "expiration_date": "2022-08-24 12:00:00", }, ) - data = self.data.picking(picking) + self.assertEqual(selected_move_line.lot_id.name, "FooBar") + self.assertEqual( + str(selected_move_line.lot_id.expiration_date), "2022-08-24 12:00:00" + ) self.assert_response( response, next_state="set_quantity", data={ - "picking": data, + "picking": self.data.picking(picking), "selected_move_line": self.data.move_lines(selected_move_line), "confirmation_required": None, }, ) + + @freeze_time("2020-01-01 11:00:00") + def test_ensure_expiry_date(self): + picking = self._create_picking() + self.product_a.use_expiration_date = True + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + # product has been set as requiring a expiration date. + # Trying to move to the next screen should return an error + lot_name = "Test Lot" + response = self.service.dispatch( + "set_lot_confirm_action", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot_name, + }, + ) + + excepted_selected_move_line_data = self.data.move_lines(selected_move_line) + # The expected response should contain the lot name even if the lot is + # not defined on the move line already + excepted_selected_move_line_data[0]["lot"] = {"name": lot_name} + + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": self.data.picking(picking), + "selected_move_line": excepted_selected_move_line_data, + }, + message=self.msg_store.expiration_date_missing(), + ) + self.assertEqual( + len(self.env["stock.lot"].search([("name", "=", lot_name)])), + 0, + "No new lot should have been created in case of error.", + ) + + def test_set_new_lot_creation_disabled_error(self): + picking = self._create_picking() + picking.picking_type_id.sudo().use_create_lots = False + + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + + lot_name = "NewForbiddenLot" + + nb_lots_before = self.env["stock.lot"].search_count([("name", "=", lot_name)]) + response = self.service.dispatch( + "set_lot_confirm_action", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot_name, + }, + ) + + # The response should keep us on 'set_lot' and show the error message + expected_selected_move_line_data = self.data.move_lines(selected_move_line) + expected_selected_move_line_data[0]["lot"] = {"name": lot_name} + + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": self.data.picking(picking), + "selected_move_line": expected_selected_move_line_data, + }, + message=self.msg_store.lot_creation_disabled(picking.picking_type_id), + ) + + nb_lots_after = self.env["stock.lot"].search_count([("name", "=", lot_name)]) + self.assertEqual(nb_lots_after, nb_lots_before) diff --git a/shopfloor_reception_mobile/README.rst b/shopfloor_reception_mobile/README.rst index c31048cc289..e678a685862 100644 --- a/shopfloor_reception_mobile/README.rst +++ b/shopfloor_reception_mobile/README.rst @@ -11,7 +11,7 @@ Shopfloor reception mobile !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:379f4cd0674b8cebf272d622ff9e2103d9cc92fdf9bb6c0b3c685d57aaa10f49 + !! source digest: sha256:5e6a2218a60dcf70dcdc216ad9005cf982c67033e066c3f7ba33e2b805dac4e2 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/shopfloor_reception_mobile/__manifest__.py b/shopfloor_reception_mobile/__manifest__.py index 3511b01fe03..074f36392d8 100644 --- a/shopfloor_reception_mobile/__manifest__.py +++ b/shopfloor_reception_mobile/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Shopfloor reception mobile", "summary": "Scenario for receiving products", - "version": "16.0.1.1.3", + "version": "16.0.1.2.0", "development_status": "Beta", "depends": ["shopfloor_mobile_base", "shopfloor_mobile", "shopfloor_reception"], "author": "Camptocamp, Odoo Community Association (OCA)", diff --git a/shopfloor_reception_mobile/static/description/index.html b/shopfloor_reception_mobile/static/description/index.html index d7c48940536..f7c11cee0fc 100644 --- a/shopfloor_reception_mobile/static/description/index.html +++ b/shopfloor_reception_mobile/static/description/index.html @@ -372,7 +372,7 @@
Frontend for the reception scenario in shopfloor.
diff --git a/shopfloor_reception_mobile/static/src/scenario/reception.js b/shopfloor_reception_mobile/static/src/scenario/reception.js
index 135091ccb0a..761e0aa6587 100644
--- a/shopfloor_reception_mobile/static/src/scenario/reception.js
+++ b/shopfloor_reception_mobile/static/src/scenario/reception.js
@@ -21,11 +21,6 @@ const Reception = {
v-on:found="on_scan"
:input_placeholder="search_input_placeholder"
/>
-