Date: Mon, 28 Apr 2025 09:23:45 +0000
Subject: [PATCH 019/357] [BOT] post-merge updates
---
README.md | 2 +-
shopfloor_reception_mobile/README.rst | 2 +-
shopfloor_reception_mobile/__manifest__.py | 2 +-
shopfloor_reception_mobile/static/description/index.html | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 71c65ba45ec..e6a6de90ba2 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ addon | version | maintainers | summary
[shopfloor_mobile_base](shopfloor_mobile_base/) | 16.0.1.1.0 | [](https://github.com/simahawk) | 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.0.0 | [](https://github.com/mmequignon) [](https://github.com/JuMiSanAr) | Reception scenario for shopfloor
-[shopfloor_reception_mobile](shopfloor_reception_mobile/) | 16.0.1.0.0 | [](https://github.com/JuMiSanAr) | Scenario for receiving products
+[shopfloor_reception_mobile](shopfloor_reception_mobile/) | 16.0.1.0.1 | [](https://github.com/JuMiSanAr) | Scenario for receiving products
[shopfloor_rest_log](shopfloor_rest_log/) | 16.0.1.0.0 | [](https://github.com/simahawk) | Integrate rest_log into Shopfloor app
[shopfloor_workstation](shopfloor_workstation/) | 16.0.1.0.0 | | Manage warehouse workstation with barcode scanners
[shopfloor_workstation_mobile](shopfloor_workstation_mobile/) | 16.0.1.0.0 | | Shopfloor mobile app integration for workstation
diff --git a/shopfloor_reception_mobile/README.rst b/shopfloor_reception_mobile/README.rst
index 91ff4b9b8f2..9e3eae11480 100644
--- a/shopfloor_reception_mobile/README.rst
+++ b/shopfloor_reception_mobile/README.rst
@@ -7,7 +7,7 @@ Shopfloor reception mobile
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
- !! source digest: sha256:53481754aeb6f45f2bc05fa4eaf8aaf55552d1ada7b26c1e7f065d60faa2adbb
+ !! source digest: sha256:160fe3ea1390fd4c12a76fba12c0f662dcb164d2e39fd9455d08e1e0bf2b4b94
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |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 a4f9bda4503..c95cac6d191 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.0.0",
+ "version": "16.0.1.0.1",
"development_status": "Beta",
"depends": ["shopfloor_mobile_base", "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 5839f8cd1c5..88ec49d8a34 100644
--- a/shopfloor_reception_mobile/static/description/index.html
+++ b/shopfloor_reception_mobile/static/description/index.html
@@ -367,7 +367,7 @@ Shopfloor reception mobile
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
-!! source digest: sha256:53481754aeb6f45f2bc05fa4eaf8aaf55552d1ada7b26c1e7f065d60faa2adbb
+!! source digest: sha256:160fe3ea1390fd4c12a76fba12c0f662dcb164d2e39fd9455d08e1e0bf2b4b94
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Frontend for the reception scenario in shopfloor.
From c5e1292161a70fcb5ccf5c0e0510b99f1e7e0a8b Mon Sep 17 00:00:00 2001
From: Mmequignon
Date: Tue, 14 Jan 2025 10:16:48 +0100
Subject: [PATCH 020/357] shopfloor delivery: better error when return picking
is scanned
---
shopfloor/actions/message.py | 10 ++
shopfloor/services/service.py | 2 +
shopfloor/tests/test_delivery_scan_deliver.py | 134 ++++++++++++++++++
3 files changed, 146 insertions(+)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index d28ad538b53..97b733d3674 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -991,3 +991,13 @@ def lot_change_no_line_found(self):
"message_type": "error",
"body": _("Unable to find a line with the same product but different lot."),
}
+
+ def reserved_for_other_picking_type(self, picking):
+ body = _("Reserved for %(picking_type)s %(picking_name)s") % {
+ "picking_type": picking.picking_type_id.name,
+ "picking_name": picking.name,
+ }
+ return {
+ "message_type": "error",
+ "body": body,
+ }
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 9f0d5844be7..8e48df0fa08 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -87,6 +87,8 @@ def _check_picking_status(self, pickings, states=("assigned",)):
return self.msg_store.transfer_cancelled()
if picking.state not in states: # the picking must be ready
return self.msg_store.stock_picking_not_available(picking)
+ if picking.picking_type_id in self.picking_types.return_picking_type_id:
+ return self.msg_store.picking_type_is_return()
if picking.picking_type_id not in self.picking_types:
return self.msg_store.cannot_move_something_in_picking_type()
diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py
index bae03f79cd9..15056e9b0bd 100644
--- a/shopfloor/tests/test_delivery_scan_deliver.py
+++ b/shopfloor/tests/test_delivery_scan_deliver.py
@@ -12,6 +12,20 @@ class DeliveryScanDeliverCase(DeliveryCommonCase):
@classmethod
def setUpClassBaseData(cls):
super().setUpClassBaseData()
+ cls.out_location = cls.env.ref("stock.stock_location_output")
+ cls.cleanup_type = (
+ cls.env["stock.picking.type"]
+ .sudo()
+ .create(
+ {
+ "name": "Cancel Cleanup",
+ "default_location_src_id": cls.out_location.id,
+ "default_location_dest_id": cls.stock_location.id,
+ "sequence_code": "CCP",
+ "code": "internal",
+ }
+ )
+ )
cls.product_e.tracking = "lot"
cls.picking = picking = cls._create_picking(
lines=[
@@ -456,6 +470,126 @@ def test_scan_deliver_picking_canceled(self):
message=self.service.msg_store.transfer_canceled(),
)
+ def test_scan_deliver_return_partial_package(self):
+ move_a = self.picking.move_ids.filtered(
+ lambda m: m.product_id == self.product_a
+ )
+ move_a._action_cancel()
+ cleanup_picking = self._create_picking(
+ picking_type=self.cleanup_type, lines=[(self.product_a, 1)]
+ )
+ package_vals = [(self.product_a, 1, None)]
+ cleanup_package = self._create_package_in_location(
+ cleanup_picking.location_id, package_vals
+ )
+ cleanup_package.name = "CLEANUP_PACKAGE"
+ cleanup_picking.action_assign()
+ cleanup_picking.move_line_ids.package_id = cleanup_package
+ params = {"barcode": "CLEANUP_PACKAGE"}
+ response = self.service.dispatch("scan_deliver", params=params)
+ type_name = cleanup_picking.picking_type_id.name
+ pick_name = cleanup_picking.name
+ expected_body = f"Reserved for {type_name} {pick_name}"
+ self.assertEqual(response.get("message").get("body"), expected_body)
+
+ def test_scan_deliver_return_package(self):
+ self.picking.action_cancel()
+ cleanup_picking = self._create_picking(
+ picking_type=self.cleanup_type, lines=[(self.product_a, 1)]
+ )
+ package_vals = [(self.product_a, 1, None)]
+ cleanup_package = self._create_package_in_location(
+ cleanup_picking.location_id, package_vals
+ )
+ cleanup_package.name = "CLEANUP_PACKAGE"
+ cleanup_picking.action_assign()
+ cleanup_picking.move_line_ids.package_id = cleanup_package
+ params = {"barcode": "CLEANUP_PACKAGE"}
+ response = self.service.dispatch("scan_deliver", params=params)
+ expected_body = (
+ f"Reserved for {cleanup_picking.picking_type_id.name} {cleanup_picking.name}"
+ )
+ self.assertEqual(response.get("message").get("body"), expected_body)
+
+ def test_scan_deliver_return_product(self):
+ self.picking.action_cancel()
+ cleanup_picking = self._create_picking(
+ picking_type=self.cleanup_type, lines=[(self.product_a, 1)]
+ )
+ cleanup_picking.action_assign()
+ params = {"barcode": self.product_a.barcode}
+ response = self.service.dispatch("scan_deliver", params=params)
+ expected_body = (
+ f"Reserved for {cleanup_picking.picking_type_id.name} {cleanup_picking.name}"
+ )
+ self.assertEqual(response.get("message").get("body"), expected_body)
+
+ def test_scan_deliver_return_packaging(self):
+ self.picking.action_cancel()
+ cleanup_picking = self._create_picking(
+ picking_type=self.cleanup_type, lines=[(self.product_a, 1)],
+ )
+ cleanup_picking.action_assign()
+ packaging = (
+ self.env["product.packaging"]
+ .sudo()
+ .create(
+ {
+ "name": "CLEANUP PACKAGING",
+ "product_id": self.product_a.id,
+ "qty": 1,
+ "product_uom_id": self.product_a.id,
+ "barcode": "CLEANUP_PACKAGING",
+ }
+ )
+ )
+ params = {"barcode": "CLEANUP_PACKAGING"}
+ response = self.service.dispatch("scan_deliver", params=params)
+ expected_body = (
+ f"Reserved for {cleanup_picking.picking_type_id.name} {cleanup_picking.name}"
+ )
+ self.assertEqual(response.get("message").get("body"), expected_body)
+
+ def test_scan_deliver_return_lot(self):
+ self.picking.action_cancel()
+ cleanup_picking = self._create_picking(
+ picking_type=self.cleanup_type, lines=[(self.product_a, 1)]
+ )
+ # Create move lines and set lot
+ cleanup_picking.action_assign()
+ cleanup_lot = self.env["stock.lot"].create(
+ {
+ "product_id": self.product_a.id,
+ "company_id": self.env.company.id,
+ "name": "CLEANUP_LOT",
+ "ref": "CLEANUP_LOT",
+ }
+ )
+ # Re-force qty to 1, as setting the lot resets qty to 0
+ cleanup_picking.move_line_ids.lot_id = cleanup_lot
+ cleanup_picking.move_line_ids.reserved_uom_qty = 1.0
+ params = {"barcode": "CLEANUP_LOT"}
+ response = self.service.dispatch("scan_deliver", params=params)
+ expected_body = (
+ f"Reserved for {cleanup_picking.picking_type_id.name} {cleanup_picking.name}"
+ )
+ self.assertEqual(response.get("message").get("body"), expected_body)
+
+ def test_scan_delivery_return_picking(self):
+ self.picking.action_cancel()
+ cleanup_picking = self._create_picking(
+ picking_type=self.cleanup_type, lines=[(self.product_c, 1)]
+ )
+ cleanup_picking.action_assign()
+ params = {"barcode": cleanup_picking.name}
+ response = self.service.dispatch("scan_deliver", params=params)
+ self.assert_response_deliver(
+ response,
+ message=self.service.msg_store.reserved_for_other_picking_type(
+ cleanup_picking
+ ),
+ )
+
def test_scan_deliver_picking_done(self):
# Set qty done for all lines (packages/raw product/lot...), picking is
# automatically set to done when the last line is completed
From 5e1412a5e5b792f0f6f4f4e63c7851292a7c32d2 Mon Sep 17 00:00:00 2001
From: Mmequignon
Date: Tue, 14 Jan 2025 15:27:00 +0100
Subject: [PATCH 021/357] shopfloor delivery: scan_delivery: use handlers
---
shopfloor/services/delivery.py | 81 ++++++++++++++++++----------------
1 file changed, 44 insertions(+), 37 deletions(-)
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 4340514edb7..768710162c1 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -147,47 +147,54 @@ def scan_deliver(self, barcode, picking_id=None, location_id=None):
if picking_id:
picking = self.env["stock.picking"].browse(picking_id)
- # Validate picking anyway
if not barcode_valid:
- package = search.package_from_scan(barcode)
- if package:
- return self._deliver_package(picking, package, location)
-
- if not barcode_valid:
- product = search.product_from_scan(barcode)
- if product:
- return self._deliver_product(
- picking, product, product_qty=1, location=location
- )
-
- if not barcode_valid:
- packaging = search.packaging_from_scan(barcode)
- if packaging:
- # By scanning a packaging, we want to process
- # the full quantity of the packaging
- packaging_qty = packaging.product_uom_id._compute_quantity(
- packaging.qty, packaging.product_id.uom_id
- )
- return self._deliver_product(
- picking,
- packaging.product_id,
- product_qty=packaging_qty,
- location=location,
- )
+ handlers_by_type = {
+ "package": self._scan_deliver__by_package,
+ "product": self._scan_deliver__by_product,
+ "packaging": self._scan_deliver__by_packaging,
+ "lot": self._scan_deliver__by_lot,
+ "location": self._scan_deliver__by_location,
+ }
+ search_result = search.find(barcode, handlers_by_type.keys())
+ handler = handlers_by_type.get(search_result.type)
+ if handler:
+ result = handler(search_result.record, picking, location)
+ if result:
+ return result
+ return self._scan_deliver__fallback(picking, location, barcode_valid)
+
+ def _scan_deliver__by_package(self, package, picking, location):
+ return self._deliver_package(picking, package, location)
+
+ def _scan_deliver__by_product(self, product, picking, location):
+ return self._deliver_product(picking, product, product_qty=1, location=location)
+
+ def _scan_deliver__by_packaging(self, packaging, picking, location):
+ # By scanning a packaging, we want to process
+ # the full quantity of the packaging
+ packaging_qty = packaging.product_uom_id._compute_quantity(
+ packaging.qty, packaging.product_id.uom_id
+ )
+ return self._deliver_product(
+ picking,
+ packaging.product_id,
+ product_qty=packaging_qty,
+ location=location,
+ )
- if not barcode_valid:
- lot = search.lot_from_scan(barcode)
- if lot:
- return self._deliver_lot(picking, lot, product_qty=1, location=location)
+ def _scan_deliver__by_lot(self, lot, picking, location):
+ return self._deliver_lot(picking, lot, product_qty=1, location=location)
- if not barcode_valid:
- sublocation = search.location_from_scan(barcode)
- if sublocation and sublocation.is_sublocation_of(
- self.picking_types.mapped("default_location_src_id")
- ):
- message = self.msg_store.location_src_set_to_sublocation(sublocation)
- return self._response_for_deliver(location=sublocation, message=message)
+ def _scan_deliver__by_location(self, scanned_location, picking, location):
+ if scanned_location.is_sublocation_of(
+ self.picking_types.mapped("default_location_src_id")
+ ):
+ message = self.msg_store.location_src_set_to_sublocation(scanned_location)
+ return self._response_for_deliver(
+ location=scanned_location, message=message
+ )
+ def _scan_deliver__fallback(self, picking, location, barcode_valid):
message = self.msg_store.barcode_not_found() if not barcode_valid else None
return self._response_for_deliver(
picking=picking, location=location, message=message
From 837dbb5166e98e53064bc25efdd1bd4278df238a Mon Sep 17 00:00:00 2001
From: Mmequignon
Date: Tue, 28 Jan 2025 14:01:02 +0100
Subject: [PATCH 022/357] shopfloor: Refactor _check_picking_status
---
shopfloor/services/checkout.py | 34 ++++----
shopfloor/services/delivery.py | 77 +++++++++++--------
shopfloor/services/service.py | 54 ++++++++-----
shopfloor/tests/test_checkout_select.py | 4 +-
shopfloor/tests/test_delivery_scan_deliver.py | 51 ++++++------
shopfloor_reception/services/reception.py | 28 +++----
6 files changed, 137 insertions(+), 111 deletions(-)
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 232e1e96613..1f143c62faf 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -402,7 +402,7 @@ def select(self, picking_id):
lines
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_manual_selection(message=message)
return self._select_picking(picking, "manual_selection")
@@ -451,7 +451,7 @@ def scan_line(self, picking_id, barcode, confirm_pack_all=False, confirm_lot=Non
screen to change the qty done and destination pack if needed
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
@@ -774,7 +774,7 @@ def select_line(self, picking_id, package_id=None, move_line_id=None):
assert package_id or move_line_id
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
@@ -793,7 +793,7 @@ def _change_line_qty(
self, picking_id, selected_line_ids, move_line_ids, quantity_func
):
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
@@ -1031,7 +1031,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
to close the stock picking
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
@@ -1186,7 +1186,7 @@ def list_delivery_packaging(self, picking_id, selected_line_ids):
* select_package: when no delivery packaging is available
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
@@ -1216,7 +1216,7 @@ def new_package(self, picking_id, selected_line_ids, package_type_id=None):
* select_line: goes back to selection of lines to work on next lines
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
packaging = None
@@ -1238,7 +1238,7 @@ def no_package(self, picking_id, selected_line_ids):
if self.options.get("checkout__disable_no_package"):
raise BadRequest("`checkout.no_package` endpoint is not enabled")
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
@@ -1270,7 +1270,7 @@ def list_dest_package(self, picking_id, selected_line_ids):
* select_package: when no package is available
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
@@ -1320,7 +1320,7 @@ def scan_dest_package(self, picking_id, selected_line_ids, barcode):
* summary: all lines are put in packages
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
@@ -1347,7 +1347,7 @@ def set_dest_package(self, picking_id, selected_line_ids, package_id):
* summary: all lines are put in packages
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
@@ -1374,7 +1374,7 @@ def summary(self, picking_id):
* summary
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
return self._response_for_summary(picking)
@@ -1393,7 +1393,7 @@ def list_packaging(self, picking_id, package_id):
* summary: if the package_id no longer exists
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
package = self.env["stock.quant.package"].browse(package_id).exists()
@@ -1408,7 +1408,7 @@ def set_packaging(self, picking_id, package_id, package_type_id):
* summary
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
@@ -1445,7 +1445,7 @@ def cancel_line(self, picking_id, package_id=None, line_id=None):
* select_line: when package or line has been canceled
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
@@ -1490,7 +1490,7 @@ def done(self, picking_id, confirmation=False):
* select_child_location: there are child destination locations
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
lines = picking.move_line_ids
@@ -1533,7 +1533,7 @@ def scan_dest_location(self, picking_id, barcode):
* select_child_location: in case of error
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_document(message=message)
search = self._actions_for("search")
diff --git a/shopfloor/services/delivery.py b/shopfloor/services/delivery.py
index 768710162c1..09ea2d9e04b 100644
--- a/shopfloor/services/delivery.py
+++ b/shopfloor/services/delivery.py
@@ -140,7 +140,7 @@ def scan_deliver(self, barcode, picking_id=None, location_id=None):
barcode_valid = bool(picking)
if picking:
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_deliver(location=location, message=message)
@@ -235,6 +235,12 @@ def _deliver_package(self, picking, package, location):
lines = package.move_line_ids.filtered(
lambda l: l.state in ("assigned", "partially_available")
)
+ if not lines:
+ return self._response_for_deliver(
+ picking=picking,
+ location=location,
+ message=self.msg_store.cannot_move_something_in_picking_type(),
+ )
# State of the picking might change while we reach this point: check again!
message = self._check_picking_status(lines.mapped("picking_id"))
if message:
@@ -247,12 +253,9 @@ def _deliver_package(self, picking, package, location):
]
)
return self._response_for_deliver(location=location, message=message)
- if not lines:
- return self._response_for_deliver(
- picking=picking,
- location=location,
- message=self.msg_store.cannot_move_something_in_picking_type(),
- )
+ message = self._check_picking_type(lines.mapped("picking_id"))
+ if message:
+ return self._response_for_deliver(location=location, message=message)
# TODO add a message if any of the lines already had a qty_done > 0
new_picking = fields.first(lines.mapped("picking_id"))
if self._set_lines_done(lines):
@@ -264,19 +267,7 @@ def _deliver_package(self, picking, package, location):
def _lines_base_domain(self, no_qty_done=True):
# we added auto_join for this, otherwise, the ORM would search all pickings
# in the picking type, and then use IN (ids)
- domain = [
- # Accepting return_picking_types in order to display meaningful
- # messages when trying to process a return move.
- # Those returns are blocked later in `_check_picking_status`
- "|",
- ("picking_id.picking_type_id", "in", self.picking_types.ids),
- (
- "picking_id.picking_type_id",
- "in",
- self.picking_types.return_picking_type_id.ids,
- ),
- ("picking_id.state", "not in", ("done",)),
- ]
+ domain = []
if no_qty_done:
domain.append(("qty_done", "=", 0))
return domain
@@ -313,6 +304,16 @@ def _lines_from_product_domain(
)
if location:
domain.extend([("location_id", "=", location.id)])
+ else:
+ domain.extend(
+ [
+ (
+ "location_id",
+ "child_of",
+ self.picking_types.default_location_src_id.ids,
+ )
+ ]
+ )
if product_qty:
domain.extend(
[
@@ -367,6 +368,12 @@ def _deliver_product(self, picking, product, product_qty=None, location=None):
message=self.msg_store.product_in_multiple_sublocation(product),
)
+ message = self._check_picking_type(lines.mapped("picking_id"))
+ if message:
+ return self._response_for_deliver(location=location, message=message)
+ lines = lines.filtered(
+ lambda l: l.move_id.picking_type_id in self.picking_types
+ )
# State of the picking might change while we reach this point: check again!
message = self._check_picking_status(lines.mapped("picking_id"))
if message:
@@ -429,15 +436,14 @@ def _deliver_product(self, picking, product, product_qty=None, location=None):
return self._response_for_deliver(new_picking, location=location)
def _deliver_lot(self, picking, lot, product_qty=None, location=None):
- lines = self.env["stock.move.line"].search(
- self._lines_from_lot_domain(
- lot,
- no_qty_done=False,
- product_qty=product_qty,
- location=location,
- picking=picking,
- )
+ domain = self._lines_from_lot_domain(
+ lot,
+ no_qty_done=False,
+ product_qty=product_qty,
+ location=location,
+ picking=picking,
)
+ lines = self.env["stock.move.line"].search(domain)
if not lines:
return self._response_for_deliver(
picking,
@@ -455,6 +461,9 @@ def _deliver_lot(self, picking, lot, product_qty=None, location=None):
message=self.msg_store.lot_in_multiple_sublocation(lot),
)
+ message = self._check_picking_type(lines.mapped("picking_id"))
+ if message:
+ return self._response_for_deliver(location=location, message=message)
# State of the picking might change while we reach this point: check again!
message = self._check_picking_status(lines.mapped("picking_id"))
if message:
@@ -565,7 +574,7 @@ def select(self, picking_id):
* deliver: with information about the stock.picking
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self.list_stock_picking(message=message)
if picking:
@@ -582,7 +591,7 @@ def set_qty_done_pack(self, picking_id, package_id, location_id=None):
* deliver: always return here with updated data
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_deliver(message=message)
package = self.env["stock.quant.package"].browse(package_id).exists()
@@ -607,7 +616,7 @@ def set_qty_done_line(self, picking_id, move_line_id):
* deliver: always return here with updated data
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_deliver(message=message)
line = self.env["stock.move.line"].browse(move_line_id).exists()
@@ -634,7 +643,7 @@ def reset_qty_done_pack(self, picking_id, package_id):
* deliver: always return here with updated data
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_deliver(message=message)
package = self.env["stock.quant.package"].browse(package_id).exists()
@@ -667,7 +676,7 @@ def reset_qty_done_line(self, picking_id, move_line_id):
* deliver: always return here with updated data
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_deliver(message=message)
line = self.env["stock.move.line"].browse(move_line_id).exists()
@@ -697,7 +706,7 @@ def done(self, picking_id, confirm=False):
* confirm_done: when not all lines of the stock.picking are done
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_deliver(message=message)
if self._action_picking_done(picking):
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index 8e48df0fa08..f55ab076407 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -2,7 +2,7 @@
# Copyright 2020 Akretion (http://www.akretion.com)
# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
-from odoo import _, exceptions
+from odoo import _, exceptions, fields
from odoo.addons.component.core import AbstractComponent
@@ -72,25 +72,41 @@ def search_move_line(self):
sort_order_custom_code=self.sort_order_custom_code,
)
- def _check_picking_status(self, pickings, states=("assigned",)):
- """Check if given pickings can be processed.
+ def _check_picking_consistency(self, pickings):
+ if not pickings.exists():
+ return self.msg_store.stock_picking_not_found()
+
+ def _check_picking_type(self, pickings):
+ """Check if the pickings have the right expected type."""
+ if not any(
+ picking.picking_type_id in self.picking_types for picking in pickings
+ ):
+ return self.msg_store.reserved_for_other_picking_type(
+ fields.first(pickings)
+ )
- If the picking is already done, canceled or didn't belong to the
- expected picking type, a message is returned.
- """
- for picking in pickings:
- if not picking.exists():
- return self.msg_store.stock_picking_not_found()
- if picking.state == "done":
- return self.msg_store.already_done()
- if picking.state == "cancel":
- return self.msg_store.transfer_cancelled()
- if picking.state not in states: # the picking must be ready
- return self.msg_store.stock_picking_not_available(picking)
- if picking.picking_type_id in self.picking_types.return_picking_type_id:
- return self.msg_store.picking_type_is_return()
- if picking.picking_type_id not in self.picking_types:
- return self.msg_store.cannot_move_something_in_picking_type()
+ def _check_picking_status(self, pickings, states=("assigned",)):
+ """Checks if the picking exists, is already done or canceled."""
+ if not any(picking.state != "done" for picking in pickings):
+ return self.msg_store.already_done()
+ if not any(picking.state != "cancel" for picking in pickings):
+ return self.msg_store.transfer_canceled()
+ if not any(
+ picking.state in states for picking in pickings
+ ): # the picking must be ready
+ return self.msg_store.stock_picking_not_available(fields.first(pickings))
+
+ def _check_picking_processible(self, pickings, states=("assigned",)):
+ """Check if given pickings can be processed"""
+ message = self._check_picking_consistency(pickings)
+ if message:
+ return message
+ message = self._check_picking_type(pickings)
+ if message:
+ return message
+ message = self._check_picking_status(pickings, states=states)
+ if message:
+ return message
def is_src_location_valid(self, location):
"""Check the source location is valid for given process.
diff --git a/shopfloor/tests/test_checkout_select.py b/shopfloor/tests/test_checkout_select.py
index 173b0aaf108..546da5e4a0a 100644
--- a/shopfloor/tests/test_checkout_select.py
+++ b/shopfloor/tests/test_checkout_select.py
@@ -68,7 +68,9 @@ def test_select_error_not_available(self):
)
def test_select_error_not_allowed(self):
+ # Trying to pick a picking with wrong picking type
picking = self._create_picking(picking_type=self.wh.pick_type_id)
self._fill_stock_for_moves(picking.move_ids, in_package=True)
picking.action_assign()
- self._test_error(picking, "You cannot move this using this menu.")
+ expected_message = f"Reserved for {picking.picking_type_id.name} {picking.name}"
+ self._test_error(picking, expected_message)
diff --git a/shopfloor/tests/test_delivery_scan_deliver.py b/shopfloor/tests/test_delivery_scan_deliver.py
index 15056e9b0bd..e9cd1dcafba 100644
--- a/shopfloor/tests/test_delivery_scan_deliver.py
+++ b/shopfloor/tests/test_delivery_scan_deliver.py
@@ -506,9 +506,9 @@ def test_scan_deliver_return_package(self):
cleanup_picking.move_line_ids.package_id = cleanup_package
params = {"barcode": "CLEANUP_PACKAGE"}
response = self.service.dispatch("scan_deliver", params=params)
- expected_body = (
- f"Reserved for {cleanup_picking.picking_type_id.name} {cleanup_picking.name}"
- )
+ type_name = cleanup_picking.picking_type_id.name
+ pick_name = cleanup_picking.name
+ expected_body = f"Reserved for {type_name} {pick_name}"
self.assertEqual(response.get("message").get("body"), expected_body)
def test_scan_deliver_return_product(self):
@@ -519,35 +519,34 @@ def test_scan_deliver_return_product(self):
cleanup_picking.action_assign()
params = {"barcode": self.product_a.barcode}
response = self.service.dispatch("scan_deliver", params=params)
- expected_body = (
- f"Reserved for {cleanup_picking.picking_type_id.name} {cleanup_picking.name}"
- )
+ type_name = cleanup_picking.picking_type_id.name
+ pick_name = cleanup_picking.name
+ expected_body = f"Reserved for {type_name} {pick_name}"
self.assertEqual(response.get("message").get("body"), expected_body)
def test_scan_deliver_return_packaging(self):
self.picking.action_cancel()
cleanup_picking = self._create_picking(
- picking_type=self.cleanup_type, lines=[(self.product_a, 1)],
+ picking_type=self.cleanup_type,
+ lines=[(self.product_a, 1)],
)
cleanup_picking.action_assign()
- packaging = (
- self.env["product.packaging"]
- .sudo()
- .create(
- {
- "name": "CLEANUP PACKAGING",
- "product_id": self.product_a.id,
- "qty": 1,
- "product_uom_id": self.product_a.id,
- "barcode": "CLEANUP_PACKAGING",
- }
- )
+
+ packaging_model = self.env["product.packaging"].sudo()
+ packaging_model.create(
+ {
+ "name": "CLEANUP PACKAGING",
+ "product_id": self.product_a.id,
+ "qty": 1,
+ "product_uom_id": self.product_a.id,
+ "barcode": "CLEANUP_PACKAGING",
+ }
)
params = {"barcode": "CLEANUP_PACKAGING"}
response = self.service.dispatch("scan_deliver", params=params)
- expected_body = (
- f"Reserved for {cleanup_picking.picking_type_id.name} {cleanup_picking.name}"
- )
+ type_name = cleanup_picking.picking_type_id.name
+ pick_name = cleanup_picking.name
+ expected_body = f"Reserved for {type_name} {pick_name}"
self.assertEqual(response.get("message").get("body"), expected_body)
def test_scan_deliver_return_lot(self):
@@ -570,9 +569,9 @@ def test_scan_deliver_return_lot(self):
cleanup_picking.move_line_ids.reserved_uom_qty = 1.0
params = {"barcode": "CLEANUP_LOT"}
response = self.service.dispatch("scan_deliver", params=params)
- expected_body = (
- f"Reserved for {cleanup_picking.picking_type_id.name} {cleanup_picking.name}"
- )
+ type_name = cleanup_picking.picking_type_id.name
+ pick_name = cleanup_picking.name
+ expected_body = f"Reserved for {type_name} {pick_name}"
self.assertEqual(response.get("message").get("body"), expected_body)
def test_scan_delivery_return_picking(self):
@@ -668,7 +667,7 @@ def test_scan_deliver_error_picking_wrong_type(self):
response,
message={
"message_type": "error",
- "body": "You cannot move this using this menu.",
+ "body": f"Reserved for {picking.picking_type_id.name} {picking.name}",
},
)
diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py
index 212711d5e08..06359a09fa8 100644
--- a/shopfloor_reception/services/reception.py
+++ b/shopfloor_reception/services/reception.py
@@ -48,13 +48,13 @@ class Reception(Component):
_usage = "reception"
_description = __doc__
- def _check_picking_status(self, pickings):
+ def _check_picking_processible(self, pickings):
# When returns are allowed,
# the created picking might be empty and cannot be assigned.
states = ["assigned"]
if self.work.menu.allow_return:
states.append("draft")
- return super()._check_picking_status(pickings, states=states)
+ return super()._check_picking_processible(pickings, states=states)
def _move_line_by_product(self, product):
return self.env["stock.move.line"].search(
@@ -328,7 +328,7 @@ def _scan_document__by_picking(self, pickings, barcode):
message=self.msg_store.cannot_move_something_in_picking_type()
)
if reception_pickings:
- message = self._check_picking_status(reception_pickings)
+ message = self._check_picking_processible(reception_pickings)
if message:
return self._response_for_select_document(
pickings=reception_pickings, message=message
@@ -939,7 +939,7 @@ def scan_line(self, picking_id, barcode):
- set_quantity: Packaging / Product has been scanned. Not tracked product
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_move(picking, message=message)
handlers_by_type = {
@@ -973,7 +973,7 @@ def done_action(self, picking_id, confirmation=False):
- select_document: Mark as done
"""
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_move(picking, message=message)
if all(line.qty_done == 0 for line in picking.move_line_ids):
@@ -1029,7 +1029,7 @@ def set_lot(
"""
picking = self.env["stock.picking"].browse(picking_id)
selected_line = self.env["stock.move.line"].browse(selected_line_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_set_lot(picking, selected_line, message=message)
if not selected_line.exists():
@@ -1060,7 +1060,7 @@ def _create_lot_values(self, product, lot_name):
def set_lot_confirm_action(self, picking_id, selected_line_id):
picking = self.env["stock.picking"].browse(picking_id)
- message = self._check_picking_status(picking)
+ 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)
@@ -1145,7 +1145,7 @@ def set_quantity(
"""
picking = self.env["stock.picking"].browse(picking_id)
selected_line = self.env["stock.move.line"].browse(selected_line_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_set_quantity(
picking, selected_line, message=message
@@ -1174,7 +1174,7 @@ def set_quantity(
def set_quantity__cancel_action(self, picking_id, selected_line_id):
picking = self.env["stock.picking"].browse(picking_id)
selected_line = self.env["stock.move.line"].browse(selected_line_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_set_quantity(
picking, selected_line, message=message
@@ -1212,7 +1212,7 @@ def _set_quantity__process__set_qty_and_split(self, picking, line, quantity):
def process_with_existing_pack(self, picking_id, selected_line_id, quantity):
picking = self.env["stock.picking"].browse(picking_id)
selected_line = self.env["stock.move.line"].browse(selected_line_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_set_quantity(
picking, selected_line, message=message
@@ -1227,7 +1227,7 @@ def process_with_existing_pack(self, picking_id, selected_line_id, quantity):
def process_with_new_pack(self, picking_id, selected_line_id, quantity):
picking = self.env["stock.picking"].browse(picking_id)
selected_line = self.env["stock.move.line"].browse(selected_line_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_set_quantity(
picking, selected_line, message=message
@@ -1243,7 +1243,7 @@ def process_with_new_pack(self, picking_id, selected_line_id, quantity):
def process_without_pack(self, picking_id, selected_line_id, quantity):
picking = self.env["stock.picking"].browse(picking_id)
selected_line = self.env["stock.move.line"].browse(selected_line_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_set_quantity(
picking, selected_line, message=message
@@ -1337,7 +1337,7 @@ def set_destination(
"""
picking = self.env["stock.picking"].browse(picking_id)
selected_line = self.env["stock.move.line"].browse(selected_line_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_set_destination(
picking, selected_line, message=message
@@ -1399,7 +1399,7 @@ def select_dest_package(
"""
picking = self.env["stock.picking"].browse(picking_id)
selected_line = self.env["stock.move.line"].browse(selected_line_id)
- message = self._check_picking_status(picking)
+ message = self._check_picking_processible(picking)
if message:
return self._response_for_select_dest_package(
picking, selected_line, message=message
From 1b65d2374f9b50e58bc2a0914fba638748abcfb3 Mon Sep 17 00:00:00 2001
From: Mmequignon
Date: Tue, 4 Feb 2025 13:56:28 +0100
Subject: [PATCH 023/357] shopfloor checkout: scan document better messages
---
shopfloor/actions/message.py | 24 ++++++
shopfloor/services/checkout.py | 107 +++++++++++++++-----------
shopfloor/tests/test_checkout_scan.py | 14 +++-
3 files changed, 95 insertions(+), 50 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index 97b733d3674..bda812a2841 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -467,6 +467,30 @@ def product_not_found_in_pickings(self):
"body": _("No transfer found for this product."),
}
+ def transfer_not_found_for_barcode(self, barcode):
+ body = _("No transfer found for barcode %s", barcode)
+ return {
+ "message_type": "error",
+ "body": body,
+ }
+
+ def transfer_not_found_for_record(self, record):
+ model_mapping = {
+ "product.product": "product",
+ "stock.picking": "transfer",
+ "stock.quant.package": "package",
+ "product.packaging": "packaging",
+ "stock.location": "location",
+ "stock.lot": "lot",
+ "stock.move": "move",
+ }
+ model_name = model_mapping.get(record._name)
+ body = _("No transfer found for %s %s", model_name, record.name)
+ return {
+ "message_type": "error",
+ "body": body,
+ }
+
def product_not_found_in_location_or_transfer(self, product, location, picking):
return {
"message_type": "error",
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 1f143c62faf..862ec082ece 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -214,25 +214,29 @@ def scan_document(self, barcode):
* summary: stock.picking is selected and all its lines have a
destination pack set
"""
- search_result = self._scan_document_find(barcode)
- result_handler = getattr(self, "_select_document_from_" + search_result.type)
- return result_handler(search_result.record)
+ handlers = {
+ "picking": self._select_document_from_picking,
+ "location": self._select_document_from_location,
+ "package": self._select_document_from_package,
+ "packaging": self._select_document_from_packaging,
+ "product": self._select_document_from_product,
+ "none": self._select_document_from_none,
+ }
+ if self.work.menu.scan_location_or_pack_first:
+ handlers.pop("product")
+ search_result = self._scan_document_find(barcode, handlers.keys())
+ # Keep track of what has been initially scan, and forward it through kwargs
+ kwargs = {
+ "barcode": barcode,
+ "current_state": "select_document",
+ "scanned_record": search_result.record,
+ }
+ handler = handlers.get(search_result.type, self._select_document_from_none)
+ return handler(search_result.record, **kwargs)
- def _scan_document_find(self, barcode, search_types=None):
+ def _scan_document_find(self, barcode, search_types):
search = self._actions_for("search")
- search_types = (
- "picking",
- "location",
- "package",
- "packaging",
- ) + (("product",) if not self.work.menu.scan_location_or_pack_first else ())
- return search.find(
- barcode,
- types=search_types,
- )
-
- def _select_document_from_picking(self, picking, **kw):
- return self._select_picking(picking, "select_document")
+ return search.find(barcode, types=search_types)
def _select_document_from_location(self, location, **kw):
if not self.is_src_location_valid(location):
@@ -251,7 +255,9 @@ def _select_document_from_location(self, location, **kw):
),
}
)
- return self._select_picking(pickings, "select_document")
+ # Keep track of what has been initially scan, and forward it through kwargs
+ kwargs = {**kw, "current_state": "select_document"}
+ return self._select_document_from_picking(pickings, **kwargs)
def _select_document_from_package(self, package, **kw):
pickings = package.move_line_ids.filtered(
@@ -260,14 +266,15 @@ def _select_document_from_package(self, package, **kw):
if len(pickings) > 1:
# Filter only if we find several pickings to narrow the
# selection to one of the good type. If we have one picking
- # of the wrong type, it will be caught in _select_picking
+ # of the wrong type, it will be caught in _select_document_from_picking
# with the proper error message.
# Side note: rather unlikely to have several transfers ready
# and moving the same things
pickings = pickings.filtered(
lambda p: p.picking_type_id in self.picking_types
)
- return self._select_picking(fields.first(pickings), "select_document")
+ kwargs = {**kw, "current_state": "select_document"}
+ return self._select_document_from_picking(fields.first(pickings), **kwargs)
def _select_document_from_product(self, product, line_domain=None, **kw):
line_domain = line_domain or []
@@ -287,7 +294,8 @@ def _select_document_from_product(self, product, line_domain=None, **kw):
order="priority desc, scheduled_date asc, id desc",
limit=1,
)
- return self._select_picking(picking, "select_document")
+ kwargs = {**kw, "current_state": "select_document"}
+ return self._select_document_from_picking(picking, **kwargs)
def _select_document_from_packaging(self, packaging, **kw):
# And retrieve its product
@@ -298,35 +306,33 @@ def _select_document_from_packaging(self, packaging, **kw):
line_domain = [("reserved_uom_qty", ">=", packaging.qty)]
return self._select_document_from_product(product, line_domain=line_domain)
- def _select_document_from_none(self, picking, **kw):
+ def _select_document_from_none(self, *args, barcode=None, **kwargs):
"""Handle result when no record is found."""
- return self._select_picking(picking, "select_document")
+ return self._response_for_select_document(
+ message=self.msg_store.transfer_not_found_for_barcode(barcode)
+ )
- def _select_picking(self, picking, state_for_error):
+ def _select_document_from_picking(
+ self, picking, current_state=None, barcode=None, **kwargs
+ ):
+ # Get origin record to give more context to the user when raising an error
+ # as we got picking from product/package/packaging/...
+ scanned_record = kwargs.get("scanned_record")
if not picking:
- if state_for_error == "manual_selection":
- return self._response_for_manual_selection(
- message=self.msg_store.stock_picking_not_found()
- )
- return self._response_for_select_document(
- message=self.msg_store.barcode_not_found()
- )
+ message = self.msg_store.transfer_not_found_for_record(scanned_record)
+ if current_state == "manual_selection":
+ return self._response_for_manual_selection(message=message)
+ return self._response_for_select_document(message=message)
if picking.picking_type_id not in self.picking_types:
- if state_for_error == "manual_selection":
- return self._response_for_manual_selection(
- message=self.msg_store.cannot_move_something_in_picking_type()
- )
- return self._response_for_select_document(
- message=self.msg_store.cannot_move_something_in_picking_type()
- )
+ message = self.msg_store.reserved_for_other_picking_type(picking)
+ if current_state == "manual_selection":
+ return self._response_for_manual_selection(message=message)
+ return self._response_for_select_document(message=message)
if picking.state != "assigned":
- if state_for_error == "manual_selection":
- return self._response_for_manual_selection(
- message=self.msg_store.stock_picking_not_available(picking)
- )
- return self._response_for_select_document(
- message=self.msg_store.stock_picking_not_available(picking)
- )
+ message = self.msg_store.stock_picking_not_available(picking)
+ if current_state == "manual_selection":
+ return self._response_for_manual_selection(message=message)
+ return self._response_for_select_document(message=message)
return self._response_for_select_line(picking)
def _data_for_move_lines(self, lines, **kw):
@@ -405,7 +411,14 @@ def select(self, picking_id):
message = self._check_picking_processible(picking)
if message:
return self._response_for_manual_selection(message=message)
- return self._select_picking(picking, "manual_selection")
+ # Because _select_document_from_picking expects some context
+ # to give meaningful infos to the user, add some here.
+ kwargs = {
+ "current_state": "manual_selection",
+ "barcode": picking.name,
+ "scanned_record": picking,
+ }
+ return self._select_document_from_picking(picking, **kwargs)
def _select_lines(self, lines, prefill_qty=0, related_lines=None):
for i, line in enumerate(lines):
@@ -1049,7 +1062,7 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
)
return result_handler(picking, selected_lines, search_result.record)
- def _scan_package_find(self, picking, barcode, search_types=None):
+ def _scan_package_find(self, picking, barcode, search_types):
search = self._actions_for("search")
search_types = (
"package",
diff --git a/shopfloor/tests/test_checkout_scan.py b/shopfloor/tests/test_checkout_scan.py
index c980814ca58..fcc17fadfa1 100644
--- a/shopfloor/tests/test_checkout_scan.py
+++ b/shopfloor/tests/test_checkout_scan.py
@@ -41,7 +41,10 @@ def test_scan_document_with_option_product_not_ok(self):
self.assert_response(
response,
next_state="select_document",
- message={"message_type": "error", "body": "Barcode not found"},
+ message={
+ "message_type": "error",
+ "body": "No transfer found for barcode A",
+ },
data={"restrict_scan_first": True},
)
@@ -56,7 +59,10 @@ def test_scan_document_error_not_found(self):
self.assert_response(
response,
next_state="select_document",
- message={"message_type": "error", "body": "Barcode not found"},
+ message={
+ "message_type": "error",
+ "body": "No transfer found for barcode NOPE",
+ },
data={"restrict_scan_first": False},
)
@@ -117,12 +123,14 @@ def _test_scan_document_error_different_picking_type(self, barcode_func):
picking.action_assign()
barcode = barcode_func(picking)
response = self.service.dispatch("scan_document", params={"barcode": barcode})
+ picking_name = picking.name
+ type_name = picking.picking_type_id.name
self.assert_response(
response,
next_state="select_document",
message={
"message_type": "error",
- "body": "You cannot move this using this menu.",
+ "body": f"Reserved for {type_name} {picking_name}",
},
data={"restrict_scan_first": False},
)
From 215346d452c0ac1d368b7e58a9b4eeec5af7ba81 Mon Sep 17 00:00:00 2001
From: Mmequignon
Date: Fri, 14 Feb 2025 15:32:16 +0100
Subject: [PATCH 024/357] shopfloor checkout: scan line better messages
---
shopfloor/actions/message.py | 14 +--
shopfloor/services/checkout.py | 93 +++++++++++--------
shopfloor/services/service.py | 17 ++++
shopfloor/tests/test_checkout_scan_line.py | 39 +++++++-
shopfloor_reception/services/reception.py | 2 +-
.../tests/test_return_scan_line.py | 2 +-
6 files changed, 114 insertions(+), 53 deletions(-)
diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py
index bda812a2841..f2c15040026 100644
--- a/shopfloor/actions/message.py
+++ b/shopfloor/actions/message.py
@@ -485,7 +485,11 @@ def transfer_not_found_for_record(self, record):
"stock.move": "move",
}
model_name = model_mapping.get(record._name)
- body = _("No transfer found for %s %s", model_name, record.name)
+ body = _(
+ "No transfer found for %(model_name)s %(record_name)s",
+ model_name=model_name,
+ record_name=record.name,
+ )
return {
"message_type": "error",
"body": body,
@@ -559,11 +563,9 @@ def place_in_location_ask_confirmation(self, location_name):
"body": _("Place it in {}?").format(location_name),
}
- def product_not_found_in_current_picking(self):
- return {
- "message_type": "error",
- "body": _("Product is not in the current transfer."),
- }
+ def product_not_found_in_current_picking(self, product):
+ body = _("Product %s is not in the current transfer.", product.name)
+ return {"message_type": "error", "body": body}
def lot_mixed_package_scan_package(self):
return {
diff --git a/shopfloor/services/checkout.py b/shopfloor/services/checkout.py
index 862ec082ece..3266260c1a6 100644
--- a/shopfloor/services/checkout.py
+++ b/shopfloor/services/checkout.py
@@ -473,21 +473,31 @@ def scan_line(self, picking_id, barcode, confirm_pack_all=False, confirm_lot=Non
return self._response_for_summary(picking)
# Search of the destination package
- 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, "confirm_lot": confirm_lot}
- return result_handler(picking, selection_lines, search_result.record, **kw)
+ handlers = {
+ "package": self._select_lines_from_package,
+ "product": self._select_lines_from_product,
+ "packaging": self._select_lines_from_packaging,
+ "lot": self._select_lines_from_lot,
+ "serial": self._select_lines_from_serial,
+ "delivery_packaging": self._select_lines_from_delivery_packaging,
+ "none": self._select_lines_from_none,
+ }
+ search_result = self._scan_line_find(picking, barcode, handlers.keys())
+ # setting scanned record as kwarg in order to make better logs.
+ # The reason for this is that from a product we might select various records
+ # and lose track of what was initially scanned. This forces us to display
+ # standard messages that might have no meaning for the user.
+ kwargs = {
+ "confirm_pack_all": confirm_pack_all,
+ "confirm_lot": confirm_lot,
+ "scanned_record": search_result.record,
+ "barcode": barcode,
+ }
+ handler = handlers.get(search_result.type, self._select_lines_from_none)
+ return handler(picking, selection_lines, search_result.record, **kwargs)
- def _scan_line_find(self, picking, barcode, search_types=None):
+ def _scan_line_find(self, picking, barcode, search_types):
search = self._actions_for("search")
- search_types = (
- "package",
- "product",
- "packaging",
- "lot",
- "serial",
- "delivery_packaging",
- )
return search.find(
barcode,
types=search_types,
@@ -510,15 +520,14 @@ def _select_lines_from_package(
lambda l: l.package_id == package and not l.shopfloor_checkout_done
)
if not lines:
- return self._response_for_select_line(
- picking,
- message={
- "message_type": "error",
- "body": _("Package {} is not in the current transfer.").format(
- package.name
- ),
- },
- )
+ # No line for scanned package in selected picking
+ # Check if there's any picking reserving this product.
+ return_picking = self._get_pickings_for_package(package, limit=1)
+ if return_picking:
+ message = self.msg_store.reserved_for_other_picking_type(return_picking)
+ else:
+ message = self.msg_store.package_not_found_in_picking(package, picking)
+ return self._response_for_select_line(picking, message=message)
self._select_lines(lines, prefill_qty=prefill_qty)
if self.work.menu.no_prefill_qty:
lines = picking.move_line_ids
@@ -535,9 +544,12 @@ def _select_lines_from_product(
lines = selection_lines.filtered(lambda l: l.product_id == product)
if not lines:
- return self._response_for_select_line(
- picking, message=self.msg_store.product_not_found_in_current_picking()
- )
+ return_picking = self._get_pickings_for_product(product, limit=1)
+ if return_picking:
+ message = self.msg_store.reserved_for_other_picking_type(return_picking)
+ else:
+ message = self.msg_store.product_not_found_in_current_picking(product)
+ return self._response_for_select_line(picking, message=message)
# When products 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.
@@ -1049,18 +1061,21 @@ def scan_package_action(self, picking_id, selected_line_ids, barcode):
return self._response_for_select_document(message=message)
selected_lines = self.env["stock.move.line"].browse(selected_line_ids).exists()
- search_result = self._scan_package_find(picking, barcode)
- message = self._check_scan_package_find(picking, search_result)
- if message:
- return self._response_for_select_package(
- picking,
- selected_lines,
- message=message,
- )
- result_handler = getattr(
- self, "_scan_package_action_from_" + search_result.type
- )
- return result_handler(picking, selected_lines, search_result.record)
+ handlers = {
+ "package": self._scan_package_action_from_package,
+ "product": self._scan_package_action_from_product,
+ "packaging": self._scan_package_action_from_packaging,
+ "lot": self._scan_package_action_from_lot,
+ "serial": self._scan_package_action_from_serial,
+ "delivery_packaging": self._scan_package_action_from_delivery_packaging,
+ }
+ search_result = self._scan_package_find(picking, barcode, handlers.keys())
+ handler = handlers.get(search_result.type, self._scan_package_action_from_none)
+ kwargs = {
+ "barcode": barcode,
+ "scanned_record": search_result.record,
+ }
+ return handler(picking, selected_lines, search_result.record, **kwargs)
def _scan_package_find(self, picking, barcode, search_types):
search = self._actions_for("search")
@@ -1081,10 +1096,6 @@ def _scan_package_find(self, picking, barcode, search_types):
),
)
- def _check_scan_package_find(self, picking, search_result):
- # Used by inheriting modules
- return False
-
def _find_line_to_increment(self, product_lines):
"""Find which line should have its qty incremented.
diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py
index f55ab076407..eb3d0592014 100644
--- a/shopfloor/services/service.py
+++ b/shopfloor/services/service.py
@@ -3,6 +3,7 @@
# Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import _, exceptions, fields
+from odoo.osv.expression import AND
from odoo.addons.component.core import AbstractComponent
@@ -34,6 +35,22 @@ def _get_process_picking_types(self):
"""Return picking types for the menu"""
return self.work.menu.picking_type_ids
+ def _get_pickings_base_domain(self):
+ return [
+ ("state", "not in", ("done", "cancel")),
+ ("location_id", "child_of", self.picking_types.default_location_src_id.ids),
+ ]
+
+ def _get_pickings_for_package(self, package, **kwargs):
+ domain = self._get_pickings_base_domain()
+ package_domain = [("move_line_ids.package_id", "=", package.id)]
+ return self.env["stock.picking"].search(AND([domain, package_domain]), **kwargs)
+
+ def _get_pickings_for_product(self, product, **kwargs):
+ domain = self._get_pickings_base_domain()
+ product_domain = [("move_line_ids.product_id", "=", product.id)]
+ return self.env["stock.picking"].search(AND([domain, product_domain]), **kwargs)
+
@property
def picking_types(self):
if not hasattr(self.work, "picking_types"):
diff --git a/shopfloor/tests/test_checkout_scan_line.py b/shopfloor/tests/test_checkout_scan_line.py
index 42e4f67ab8f..8aa12933bd4 100644
--- a/shopfloor/tests/test_checkout_scan_line.py
+++ b/shopfloor/tests/test_checkout_scan_line.py
@@ -164,6 +164,24 @@ def test_scan_line_error_barcode_not_found(self):
)
def test_scan_line_error_package_not_in_picking(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking.move_ids, in_package=True)
+ picking.action_assign()
+ # Create a package for product_a
+ package = self._create_package_in_location(
+ picking.location_id, [(self.product_a, 10, None)]
+ )
+ # we work with picking, but we scan another package (not in a pick)
+ self._test_scan_line_error(
+ picking,
+ package.name,
+ {
+ "message_type": "error",
+ "body": f"Package {package.name} not found in transfer {picking.name}",
+ },
+ )
+
+ def test_scan_line_error_package_reserved_by_another_picking(self):
picking = self._create_picking(lines=[(self.product_a, 10)])
self._fill_stock_for_moves(picking.move_ids, in_package=True)
picking2 = self._create_picking(lines=[(self.product_a, 10)])
@@ -176,9 +194,7 @@ def test_scan_line_error_package_not_in_picking(self):
package.name,
{
"message_type": "error",
- "body": "Package {} is not in the current transfer.".format(
- package.name
- ),
+ "body": f"Reserved for Checkout {picking2.name}",
},
)
@@ -247,7 +263,22 @@ def test_scan_line_error_product_not_in_picking(self):
self.product_b.barcode,
{
"message_type": "error",
- "body": "Product is not in the current transfer.",
+ "body": "Product Product B is not in the current transfer.",
+ },
+ )
+
+ def test_scan_line_error_product_in_another_picking(self):
+ picking = self._create_picking(lines=[(self.product_a, 10)])
+ self._fill_stock_for_moves(picking.move_ids, in_package=True)
+ picking2 = self._create_picking(lines=[(self.product_b, 10)])
+ self._fill_stock_for_moves(picking2.move_ids, in_package=True)
+ (picking | picking2).action_assign()
+ self._test_scan_line_error(
+ picking,
+ self.product_b.barcode,
+ {
+ "message_type": "error",
+ "body": f"Reserved for Checkout {picking2.name}",
},
)
diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py
index 06359a09fa8..8eab102accd 100644
--- a/shopfloor_reception/services/reception.py
+++ b/shopfloor_reception/services/reception.py
@@ -412,7 +412,7 @@ def _scan_line__by_product__return(self, picking, product):
# If we have an origin picking but no origin move, then user
# scanned a wrong product. Warn him about this.
if origin_moves and not origin_moves_for_product:
- message = self.msg_store.product_not_found_in_current_picking()
+ message = self.msg_store.product_not_found_in_current_picking(product)
return self._response_for_select_move(picking, message=message)
if origin_moves_for_product:
return_move = self._scan_line__create_return_move(
diff --git a/shopfloor_reception/tests/test_return_scan_line.py b/shopfloor_reception/tests/test_return_scan_line.py
index 9575ebdbf1d..76aa9c538f6 100644
--- a/shopfloor_reception/tests/test_return_scan_line.py
+++ b/shopfloor_reception/tests/test_return_scan_line.py
@@ -22,7 +22,7 @@ def test_scan_product_not_in_delivery(self):
data={"picking": self._data_for_picking_with_moves(return_picking)},
message={
"message_type": "error",
- "body": "Product is not in the current transfer.",
+ "body": f"Product {wrong_product.name} is not in the current transfer.",
},
)
From a42a8f7c7f7a0783eb2c0be4f4a5dc55a5c42536 Mon Sep 17 00:00:00 2001
From: Mmequignon
Date: Mon, 17 Mar 2025 15:40:43 +0100
Subject: [PATCH 025/357] shopfloor: location_content_transfer: Better messages
---
shopfloor/actions/stock_unreserve.py | 15 ++++--
.../services/location_content_transfer.py | 52 ++++++++++++-------
.../test_location_content_transfer_start.py | 25 ++++++++-
3 files changed, 69 insertions(+), 23 deletions(-)
diff --git a/shopfloor/actions/stock_unreserve.py b/shopfloor/actions/stock_unreserve.py
index b279a5d9082..a86c0c917f1 100644
--- a/shopfloor/actions/stock_unreserve.py
+++ b/shopfloor/actions/stock_unreserve.py
@@ -10,7 +10,9 @@ class StockUnreserve(Component):
_inherit = "shopfloor.process.action"
_usage = "stock.unreserve"
- def check_unreserve(self, location, move_lines, product=None, lot=None):
+ def check_unreserve(
+ self, location, move_lines, product=None, lot=None, allowed_types=None
+ ):
"""Return a message if there is an ongoing operation in the location.
It could be a move line with some qty already processed or another
@@ -20,12 +22,17 @@ def check_unreserve(self, location, move_lines, product=None, lot=None):
:param move_lines: move lines to unreserve
:param product: optional product to limit the scope in the location
"""
+ if not allowed_types:
+ allowed_types = self.env["stock.picking.type"]
location_move_lines = self._find_location_all_move_lines(location, product, lot)
extra_move_lines = location_move_lines - move_lines
if extra_move_lines:
- return self.msg_store.picking_already_started_in_location(
- extra_move_lines.picking_id
- )
+ extra_pickings = extra_move_lines.picking_id
+ if allowed_types:
+ for picking in extra_pickings:
+ if picking.picking_type_id not in allowed_types:
+ return self.msg_store.reserved_for_other_picking_type(picking)
+ return self.msg_store.picking_already_started_in_location(extra_pickings)
def unreserve_moves(self, move_lines, picking_types):
"""Unreserve moves from `move_lines'.
diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py
index 9f5408bfde1..2983d084075 100644
--- a/shopfloor/services/location_content_transfer.py
+++ b/shopfloor/services/location_content_transfer.py
@@ -342,7 +342,9 @@ def scan_location(self, barcode): # noqa: C901
unreserved_moves = self.env["stock.move"].browse()
if self.work.menu.allow_unreserve_other_moves:
- message = unreserve.check_unreserve(location, move_lines)
+ message = unreserve.check_unreserve(
+ location, move_lines, allowed_types=self.picking_types
+ )
if message:
return self._response_for_start(message=message)
move_lines, unreserved_moves = unreserve.unreserve_moves(
@@ -602,20 +604,31 @@ def scan_line(self, location_id, move_line_id, barcode):
)
search = self._actions_for("search")
+ handlers = {
+ "package": self._scan_line__by_package,
+ "product": self._scan_line__by_product,
+ "packaging": self._scan_line__by_packaging,
+ "lot": self._scan_line__by_lot,
+ "none": self._scan_line__fallback,
+ }
+ search_result = search.find(barcode, types=handlers.keys())
+ handler = handlers.get(search_result.type, self._scan_line__fallback)
+ # handler might've been called but returned no response.
+ # I.E. package is scanned but doesn't matches move_line's package.
+ # Call explicitely fallback in such case
+ response = handler(search_result.record, move_line, location)
+ return response or self._scan_line__fallback(
+ search_result.record, move_line, location
+ )
- package = search.package_from_scan(barcode)
- if package and move_line.package_id == package:
+ def _scan_line__by_package(self, package, move_line, location):
+ if move_line.package_id == package:
# In case we have a source package but no package level because if
# we have a package level, we would use "scan_package".
return self._response_for_scan_destination(location, move_line)
- product = search.product_from_scan(barcode)
- if not product:
- packaging = search.packaging_from_scan(barcode)
- if packaging:
- product = packaging.product_id
-
- if product and product == move_line.product_id:
+ def _scan_line__by_product(self, product, move_line, location):
+ if product == move_line.product_id:
if product.tracking in ("lot", "serial"):
move_lines = self._find_transfer_move_lines(location)
return self._response_for_start_single(
@@ -625,18 +638,21 @@ def scan_line(self, location_id, move_line_id, barcode):
else:
return self._response_for_scan_destination(location, move_line)
- lot = search.lot_from_scan(barcode, products=move_line.product_id)
- if lot and lot == move_line.lot_id:
+ def _scan_line__by_packaging(self, packaging, move_line, location):
+ return self._scan_line__by_product(packaging.product_id, move_line, location)
+
+ def _scan_line__by_lot(self, lot, move_line, location):
+ if lot == move_line.lot_id:
return self._response_for_scan_destination(location, move_line)
+ def _scan_line__fallback(self, record, move_line, location):
# Nothing matches what is expected from the move line.
move_lines = self._find_transfer_move_lines(location)
- for rec in (package, product, lot):
- if rec:
- return self._response_for_start_single(
- move_lines.mapped("picking_id"),
- message=self.msg_store.wrong_record(rec),
- )
+ if record:
+ return self._response_for_start_single(
+ move_lines.mapped("picking_id"),
+ message=self.msg_store.wrong_record(record),
+ )
return self._response_for_start_single(
move_lines.mapped("picking_id"), message=self.msg_store.barcode_not_found()
)
diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py
index d51597ee981..62bc1fcd6a2 100644
--- a/shopfloor/tests/test_location_content_transfer_start.py
+++ b/shopfloor/tests/test_location_content_transfer_start.py
@@ -275,6 +275,29 @@ def test_scan_location_wrong_picking_type_allow_unreserve_empty(self):
message=self.service.msg_store.location_empty(self.content_loc),
)
+ def test_scan_location_picking_already_started(self):
+ self.menu.sudo().allow_unreserve_other_moves = True
+ picking = self._create_picking(
+ picking_type=self.menu.picking_type_ids,
+ lines=[(self.product_a, 10), (self.product_b, 10)],
+ )
+ self._fill_stock_for_moves(
+ picking.move_ids, in_package=True, location=self.content_loc
+ )
+ picking.action_assign()
+ picking.move_line_ids[0].qty_done = 10
+ response = self.service.dispatch(
+ "scan_location", params={"barcode": self.content_loc.barcode}
+ )
+ self.assert_response_start(
+ response,
+ message=self.service.msg_store.picking_already_started_in_location(picking),
+ )
+ # check that the original moves are still assigned
+ self.assertRecordValues(
+ picking.move_ids, [{"state": "assigned"}, {"state": "assigned"}]
+ )
+
def test_scan_location_wrong_picking_type_allow_unreserve_error(self):
"""Content has different picking type than menu, option to unreserve
@@ -298,7 +321,7 @@ def test_scan_location_wrong_picking_type_allow_unreserve_error(self):
)
self.assert_response_start(
response,
- message=self.service.msg_store.picking_already_started_in_location(picking),
+ message=self.service.msg_store.reserved_for_other_picking_type(picking),
)
# check that the original moves are still assigned
self.assertRecordValues(
From f1a593fd65528a696dbae7c46445059548feab0e Mon Sep 17 00:00:00 2001
From: Mmequignon
Date: Mon, 13 Jan 2025 14:09:23 +0100
Subject: [PATCH 026/357] stock_available_to_promise_release: Allow unrelease
processed qties
---
.../models/stock_move.py | 198 ++++++++++++---
.../models/stock_route.py | 10 +
.../models/stock_rule.py | 4 +
.../tests/__init__.py | 1 +
.../tests/common.py | 16 +-
.../tests/test_unrelease_cancel.py | 238 ++++++++++++++++++
.../views/stock_route_views.xml | 1 +
7 files changed, 435 insertions(+), 33 deletions(-)
create mode 100644 stock_available_to_promise_release/tests/test_unrelease_cancel.py
diff --git a/stock_available_to_promise_release/models/stock_move.py b/stock_available_to_promise_release/models/stock_move.py
index 946593abe47..102fd5649ae 100644
--- a/stock_available_to_promise_release/models/stock_move.py
+++ b/stock_available_to_promise_release/models/stock_move.py
@@ -5,11 +5,12 @@
import itertools
import logging
import operator as py_operator
+from collections import defaultdict
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.osv import expression
-from odoo.tools import date_utils, float_compare, float_round, groupby
+from odoo.tools import date_utils, float_compare, float_is_zero, float_round, groupby
from odoo.addons.stock.models.stock_move import StockMove as StockMoveBase
@@ -87,35 +88,42 @@ def _is_unreleaseable(self):
and self.rule_id.available_to_promise_defer_pull
)
+ def _has_unreleasable_state(self):
+ self.ensure_one()
+ if self.rule_id.allow_unrelease_return_done_move:
+ blocking_states = ("cancel",)
+ else:
+ blocking_states = ("done", "cancel")
+ return self.state not in blocking_states
+
def _in_progress_for_unrelease(self) -> StockMoveBase:
"""
- This method will return the moves not done or canceled that :
+ This method will return the moves with unreleasable state that :
- have their picking printed
- - have a quantity done != 0
-
+ - have a quantity done set if allow_unrelease_return_done_move
"""
- moves = self.filtered(lambda m: m.state not in ("done", "cancel"))
- if not moves:
- return moves
- moves_printed = moves.filtered("picking_id.printed")
- if moves_printed:
- return moves_printed
- moves_done = moves.filtered("quantity_done")
- if moves_done:
- return moves_done
- return moves.browse()
+ unreleasable_moves = self.filtered(lambda m: m._has_unreleasable_state())
+ if not unreleasable_moves:
+ return unreleasable_moves
+ printed_pickings = unreleasable_moves.filtered("picking_id.printed")
+ if printed_pickings:
+ return printed_pickings
+ return unreleasable_moves.filtered(
+ lambda m: not m.rule_id.allow_unrelease_return_done_move and m.quantity_done
+ )
def _is_unrelease_allowed_on_origin_moves(self, origin_moves):
"""We check that the origin moves are in a state that allows the unrelease
of the current move. At this stage, a move can't be unreleased if
- * a picking is already printed. (The work on the picking is planned and
- we don't want to change it)
- * a quantity done is recorded
- * the processed origin moves is not consumed by the dest moves.
+ the processed origin moves is not consumed by the dest moves.
"""
self.ensure_one()
origin_done_moves = origin_moves.filtered(lambda m: m.state == "done")
+ if self.rule_id.allow_unrelease_return_done_move:
+ origin_done_moves = origin_done_moves.filtered(
+ lambda m: not m.picking_type_id.return_picking_type_id
+ )
origin_qty_done = sum(
m.product_uom._compute_quantity(
m.quantity_done,
@@ -672,6 +680,81 @@ def _get_chained_moves_iterator(self, chain_field):
visited_moves += moves
moves = moves.mapped(chain_field) - visited_moves
+ def _return_quantity_in_stock(self, qty_to_return_per_move):
+ """Return a quantity from a list of moves.
+
+ The quantity to return is in the product uom"""
+ moves_to_return = self.browse([m_id for m_id in qty_to_return_per_move.keys()])
+ moves_per_type = groupby(moves_to_return, lambda m: m.picking_type_id)
+ for picking_type, moves_list in moves_per_type:
+ moves = self.browse().union(*moves_list)
+ pickings = moves.picking_id
+ if not pickings:
+ continue
+ return_type = picking_type.return_picking_type_id
+ wiz_values = {
+ "picking_id": fields.first(pickings).id,
+ "location_id": return_type.default_location_dest_id.id,
+ }
+ product_return_moves = []
+ if not return_type:
+ message = _(
+ "The operation %(picking_names)s is done and cannot be returned",
+ picking_names=", ".join(pickings.mapped("name")),
+ )
+ raise UserError(message)
+ for move in moves:
+ # Cannot return an unprocessed move
+ if move.state != "done":
+ continue
+ product = move.product_id
+ uom = product.uom_id
+ qty_to_return = qty_to_return_per_move.get(move.id, 0)
+ # Cannot return 0 qty
+ if float_is_zero(qty_to_return, precision_rounding=uom.rounding):
+ continue
+ return_move_vals = {
+ "product_id": product.id,
+ "quantity": qty_to_return,
+ "uom_id": uom.id,
+ "move_id": move.id,
+ }
+ product_return_moves.append((0, 0, return_move_vals))
+ if product_return_moves:
+ wiz_values["product_return_moves"] = product_return_moves
+ return_wiz = self.env["stock.return.picking"].create(wiz_values)
+ return_wiz.create_returns()
+ return True
+
+ def _unrelease_set_returnable_qty_per_move(
+ self, qty_to_return, qty_to_return_per_move
+ ):
+ returnable_qty = 0
+ for move in self:
+ rounding = move.product_id.uom_id.rounding
+ # As a move might have multiple dest ids, we might have
+ # already planned to return a few units already.
+ # Get it, and deduce it from the returnable qty
+ move_qty_planned = qty_to_return_per_move.get(move.id, 0)
+ # A move might already have return moves linked to it, deduce their quantity
+ move_returned_qty = sum(
+ move.returned_move_ids.filtered(lambda m: m.state != "cancel").mapped(
+ "product_qty"
+ )
+ )
+ move_returnable_qty = min(
+ qty_to_return, move.product_qty - move_returned_qty - move_qty_planned
+ )
+ if float_is_zero(move_returnable_qty, precision_rounding=rounding):
+ continue
+ # Update the quantity
+ qty_to_return_per_move[move.id] += move_returnable_qty
+ qty_to_return -= move_returnable_qty
+ returnable_qty += move_returnable_qty
+ if float_is_zero(qty_to_return, precision_rounding=rounding):
+ break
+ return returnable_qty
+
def unrelease(self, safe_unrelease=False):
"""Unrelease unreleasable moves
@@ -685,29 +768,83 @@ def unrelease(self, safe_unrelease=False):
if forbidden_moves:
forbidden_moves._unrelease_not_allowed_error()
moves_to_unrelease.write({"need_release": True})
- impacted_picking_ids = set()
+ qty_to_return_per_move = defaultdict(float)
for move in moves_to_unrelease:
+ rounding = move.product_id.uom_id.rounding
+ # When a move is returned, it is going straight to WH/Stock,
+ # skipping all intermediate zones (pick/pack).
+ # That is why we need to keep track of qty returned along the way.
+ # We do not want to return the same goods at each step.
+ # At a given step (pick/pack/ship), qty to return is
+ # move.product_uom_qty - cancelled_qty_at_step - already returned qties
+ qty_to_unrelease = move.product_qty
+ qty_returned_for_move = 0
iterator = move._get_chained_moves_iterator("move_orig_ids")
- moves_to_cancel = self.env["stock.move"]
+ moves_to_cancel_for_move = self.env["stock.move"]
# backup procure_method as when you don't propagate cancel, the
# destination move is forced to make_to_stock
procure_method = move.procure_method
next(iterator) # skip the current move
for origin_moves in iterator:
- origin_moves = origin_moves.filtered(
+ qty_to_cancel = qty_to_unrelease - qty_returned_for_move
+ if float_is_zero(qty_to_cancel, precision_rounding=rounding):
+ break
+ todo_origin_moves = origin_moves.filtered(
lambda m: m.state not in ("done", "cancel")
)
- if origin_moves:
- origin_moves = move._split_origins(origin_moves)
- impacted_picking_ids.update(origin_moves.mapped("picking_id").ids)
+ qty_canceled = 0
+ if todo_origin_moves:
+ moves_to_cancel = move._split_origins(
+ todo_origin_moves, qty=qty_to_cancel
+ )
# avoid to propagate cancel to the original move
- origin_moves.write({"propagate_cancel": False})
- # origin_moves._action_cancel()
- moves_to_cancel |= origin_moves
- moves_to_cancel._action_cancel()
+ moves_to_cancel.write({"propagate_cancel": False})
+ moves_to_cancel_for_move |= moves_to_cancel
+ qty_canceled = sum(moves_to_cancel.mapped("product_qty"))
+ # checking that for the current step (pick/pack/ship)
+ # move.product_uom_qty == step.cancelled_qty + move.returned_quanty
+ # If not the case, we have to move back goods in stock.
+ qty_to_return = qty_to_cancel - qty_canceled
+ done_moves = origin_moves.filtered(lambda m: m.state == "done")
+ # in case of canceled origin_moves, the quantity to return must
+ # be limited to the quantity not consumed
+ done_dest_moves = done_moves.move_dest_ids.filtered(
+ lambda m: m.state == "done"
+ )
+ returnable_qty = sum(done_moves.mapped("product_qty")) - sum(
+ done_dest_moves.mapped("product_qty")
+ )
+ qty_to_return = min(qty_to_return, returnable_qty)
+ if float_compare(qty_to_return, 0, precision_rounding=rounding) <= 0:
+ continue
+ if not move.rule_id.allow_unrelease_return_done_move:
+ # Without allow_unrelease_return_done_move enabled,
+ # only moves that aren't done can be unreleased.
+ msg_args = {
+ "move_name": move.name,
+ "done_move_names": ", ".join(done_moves.mapped("name")),
+ }
+ message = _(
+ (
+ "You cannot unrelease the move %(move_name)s "
+ "because some origin moves %(done_move_names)s are done"
+ ),
+ **msg_args
+ )
+ raise UserError(message)
+ # Multiple pickings can satisfy a move
+ # -> len(move.move_orig_ids.picking_id) > 1
+ # Group done_moves per picking, and create returns
+ returnable_qty = done_moves._unrelease_set_returnable_qty_per_move(
+ qty_to_return, qty_to_return_per_move
+ )
+ qty_returned_for_move += returnable_qty
+
+ moves_to_cancel_for_move._action_cancel()
# restore the procure_method overwritten by _action_cancel()
move.procure_method = procure_method
+ self._return_quantity_in_stock(qty_to_return_per_move)
moves_to_unrelease.write({"need_release": True})
for picking, moves in itertools.groupby(
moves_to_unrelease, lambda m: m.picking_id
@@ -721,14 +858,15 @@ def unrelease(self, safe_unrelease=False):
)
picking.message_post(body=body)
- def _split_origins(self, origins):
+ def _split_origins(self, origins, qty=None):
"""Split the origins of the move according to the quantity into the
move and the quantity in the origin moves.
Return the origins for the move's quantity.
"""
self.ensure_one()
- qty = self.product_qty
+ if not qty:
+ qty = self.product_qty
# Unreserve goods before the split
origins._do_unreserve()
rounding = self.product_uom.rounding
diff --git a/stock_available_to_promise_release/models/stock_route.py b/stock_available_to_promise_release/models/stock_route.py
index 7c91e368b37..b7d32ea9e93 100644
--- a/stock_available_to_promise_release/models/stock_route.py
+++ b/stock_available_to_promise_release/models/stock_route.py
@@ -7,6 +7,16 @@
class StockRoute(models.Model):
_inherit = "stock.route"
+ allow_unrelease_return_done_move = fields.Boolean(
+ string="Reverse done transfer on cancellation",
+ default=False,
+ help=(
+ "If checked, unreleasing the delivery may create a new inverse "
+ "internal operation on the last done pulled transfer. "
+ "Otherwise, you won't be able to unrelease as soon as one of "
+ "the pulled transfer is done"
+ ),
+ )
available_to_promise_defer_pull = fields.Boolean(
string="Release based on Available to Promise",
default=False,
diff --git a/stock_available_to_promise_release/models/stock_rule.py b/stock_available_to_promise_release/models/stock_rule.py
index 7c893df5eab..be7c2516761 100644
--- a/stock_available_to_promise_release/models/stock_rule.py
+++ b/stock_available_to_promise_release/models/stock_rule.py
@@ -14,6 +14,10 @@ class StockRule(models.Model):
related="route_id.available_to_promise_defer_pull", store=True
)
+ allow_unrelease_return_done_move = fields.Boolean(
+ related="route_id.allow_unrelease_return_done_move", store=True
+ )
+
no_backorder_at_release = fields.Boolean(
related="route_id.no_backorder_at_release", store=True
)
diff --git a/stock_available_to_promise_release/tests/__init__.py b/stock_available_to_promise_release/tests/__init__.py
index 35a813d9a28..41181f76399 100644
--- a/stock_available_to_promise_release/tests/__init__.py
+++ b/stock_available_to_promise_release/tests/__init__.py
@@ -4,3 +4,4 @@
from . import test_unrelease_2steps
from . import test_unrelease_3steps
from . import test_unrelease_merged_moves
+from . import test_unrelease_cancel
diff --git a/stock_available_to_promise_release/tests/common.py b/stock_available_to_promise_release/tests/common.py
index 6fd56df73af..c0a47307756 100644
--- a/stock_available_to_promise_release/tests/common.py
+++ b/stock_available_to_promise_release/tests/common.py
@@ -132,8 +132,18 @@ def _out_picking(cls, pickings):
return pickings.filtered(lambda r: r.picking_type_code == "outgoing")
@classmethod
- def _deliver(cls, picking):
+ def _get_backorder_for_pickings(cls, pickings):
+ return cls.env["stock.picking"].search([("backorder_id", "in", pickings.ids)])
+
+ @classmethod
+ def _deliver(cls, picking, product_qty=None):
picking.action_assign()
- for line in picking.mapped("move_ids.move_line_ids"):
- line.qty_done = line.reserved_qty
+ if product_qty:
+ lines = picking.move_ids.move_line_ids
+ for product, qty in product_qty:
+ line = lines.filtered(lambda m: m.product_id == product)
+ line.qty_done = qty
+ else:
+ for line in picking.mapped("move_ids.move_line_ids"):
+ line.qty_done = line.reserved_qty
picking._action_done()
diff --git a/stock_available_to_promise_release/tests/test_unrelease_cancel.py b/stock_available_to_promise_release/tests/test_unrelease_cancel.py
new file mode 100644
index 00000000000..b3dc36fe5cd
--- /dev/null
+++ b/stock_available_to_promise_release/tests/test_unrelease_cancel.py
@@ -0,0 +1,238 @@
+# Copyright 2025 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+from datetime import datetime
+
+from .common import PromiseReleaseCommonCase
+
+
+class TestAvailableToPromiseReleaseCancel(PromiseReleaseCommonCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.wh.delivery_steps = "pick_pack_ship"
+ cls._update_qty_in_location(cls.loc_bin1, cls.product1, 50.0)
+ cls._update_qty_in_location(cls.loc_bin1, cls.product2, 50.0)
+
+ delivery_route = cls.wh.delivery_route_id
+ ship_rule = delivery_route.rule_ids.filtered(
+ lambda r: r.location_dest_id == cls.loc_customer
+ )
+ cls.loc_output = ship_rule.location_src_id
+ pack_rule = delivery_route.rule_ids.filtered(
+ lambda r: r.location_dest_id == cls.loc_output
+ )
+ cls.loc_pack = pack_rule.location_src_id
+ pick_rule = delivery_route.rule_ids.filtered(
+ lambda r: r.location_dest_id == cls.loc_pack
+ )
+ cls.pick_type = pick_rule.picking_type_id
+ cls.pack_type = pack_rule.picking_type_id
+
+ cls.picking_chain = cls._create_picking_chain(
+ cls.wh, [(cls.product1, 10)], date=datetime(2019, 9, 2, 16, 0)
+ )
+ cls.ship_picking = cls._out_picking(cls.picking_chain)
+ cls.pack_picking = cls._prev_picking(cls.ship_picking)
+ cls.pick_picking = cls._prev_picking(cls.pack_picking)
+
+ # Why is this not working when creating picking after enabling this setting?
+ delivery_route.write(
+ {
+ "available_to_promise_defer_pull": True,
+ "allow_unrelease_return_done_move": True,
+ }
+ )
+ cls.ship_picking.release_available_to_promise()
+ cls.cleanup_type = cls.env["stock.picking.type"].create(
+ {
+ "name": "Cancel Cleanup",
+ "default_location_dest_id": cls.loc_stock.id,
+ "sequence_code": "CCP",
+ "code": "internal",
+ }
+ )
+ cls.pick_type.return_picking_type_id = cls.cleanup_type
+ cls.pack_type.return_picking_type_id = cls.cleanup_type
+
+ @classmethod
+ def _get_cleanup_picking(cls):
+ return cls.env["stock.picking"].search(
+ [("picking_type_id", "=", cls.cleanup_type.id)]
+ )
+
+ def test_unrelease_picked(self):
+ # In this case, we should get 1 return picking from
+ # WH/PACK to WH/STOCK
+ self._deliver(self.pick_picking)
+ self.ship_picking.unrelease()
+ self.assertTrue(self.ship_picking.need_release)
+ self.assertEqual(self.pack_picking.state, "cancel")
+ self.assertEqual(self.pick_picking.state, "done")
+ cancel_picking = self._get_cleanup_picking()
+ self.assertEqual(len(cancel_picking), 1)
+ self.assertEqual(cancel_picking.location_id, self.loc_pack)
+ self.assertEqual(cancel_picking.location_dest_id, self.loc_stock)
+
+ def test_unrelease_packed(self):
+ # In this case, we should get 1 return picking from
+ # WH/OUT to WH/STOCK
+ self._deliver(self.pick_picking)
+ self._deliver(self.pack_picking)
+ self.ship_picking.unrelease()
+ self.assertTrue(self.ship_picking.need_release)
+ self.assertEqual(self.pack_picking.state, "done")
+ self.assertEqual(self.pick_picking.state, "done")
+ cancel_picking = self._get_cleanup_picking()
+ self.assertEqual(len(cancel_picking), 1)
+ self.assertEqual(cancel_picking.location_id, self.loc_output)
+ self.assertEqual(cancel_picking.location_dest_id, self.loc_stock)
+
+ def test_unrelease_picked_partial(self):
+ qty_picked = [(self.product1, 5.0)]
+ self._deliver(self.pick_picking, product_qty=qty_picked)
+ pick_backorder = self._get_backorder_for_pickings(self.pick_picking)
+ self.assertTrue(pick_backorder)
+ self.ship_picking.unrelease()
+ self.assertTrue(self.ship_picking.need_release)
+ self.assertEqual(self.pack_picking.state, "cancel")
+ self.assertEqual(self.pick_picking.state, "done")
+ cancel_picking = self._get_cleanup_picking()
+ # In the end, we cancelled 5 units for the pick backorder, and returned
+ # 5 units from pack -> stock
+ self.assertEqual(pick_backorder.state, "cancel")
+ self.assertEqual(cancel_picking.location_id, self.loc_pack)
+ self.assertEqual(cancel_picking.location_dest_id, self.loc_stock)
+ self.assertEqual(cancel_picking.move_ids.product_uom_qty, 5.0)
+
+ def test_unrelease_packed_partial(self):
+ self._deliver(self.pick_picking)
+ qty_packed = [(self.product1, 5.0)]
+ self._deliver(self.pack_picking, product_qty=qty_packed)
+ pack_backorder = self._get_backorder_for_pickings(self.pack_picking)
+ self.assertTrue(pack_backorder)
+ self.ship_picking.unrelease()
+ self.assertTrue(self.ship_picking.need_release)
+ self.assertEqual(self.pack_picking.state, "done")
+ self.assertEqual(self.pick_picking.state, "done")
+ cancel_pickings = self._get_cleanup_picking()
+ self.assertEqual(len(cancel_pickings), 2)
+ # In the end, we cancelled 5 units for the pack backorder, returned
+ # 5 units from pack -> stock, and 5 units from output -> stock
+ pack_cancel = cancel_pickings.filtered(lambda p: p.location_id == self.loc_pack)
+ ship_cancel = cancel_pickings.filtered(
+ lambda p: p.location_id == self.loc_output
+ )
+ self.assertEqual(pack_cancel.move_ids.product_uom_qty, 5.0)
+ self.assertEqual(ship_cancel.move_ids.product_uom_qty, 5.0)
+
+ @classmethod
+ def put_in_pack(cls, move):
+ # is it necessary to create stock moves?
+ move._action_assign()
+ pack = cls.env["stock.quant.package"].create({"name": move.product_id.name})
+ move.move_line_ids.result_package_id = pack
+ return pack
+
+ def test_unrelease_multiple_moves_same_product(self):
+ # Create a picking with twice the same move
+ product_qty = [
+ (self.product1, 20),
+ ]
+ picking_chain = self._create_picking_chain(self.wh, products=product_qty)
+ ship_picking = self._out_picking(picking_chain)
+ ship_picking.release_available_to_promise()
+ # Creating a second move. Both moves thave the same origin (pack.move_line)
+ self.env["stock.move"].create(ship_picking.move_ids._split(4))
+ pack_picking = self._prev_picking(ship_picking)
+ pick_picking = self._prev_picking(pack_picking)
+ self._deliver(pick_picking)
+ self._deliver(pack_picking)
+ ship_picking.unrelease()
+ cancel_pickings = self._get_cleanup_picking()
+ self.assertEqual(cancel_pickings.move_ids.product_qty, 20)
+
+ def test_unrelease_packed_multi(self):
+ # Pick and pack 2 pickings, unrelease both before shipping
+ # Both have same picking types, goods should be returned
+ # to stock in the same picking
+ ship_no_pack = self.ship_picking
+ pack_no_pack = self.pack_picking
+ pick_no_pack = self.pick_picking
+ # The new picking chain will have packages
+ product_qty = [(self.product1, 10), (self.product2, 10)]
+ picking_chain = self._create_picking_chain(self.wh, products=product_qty)
+ ship_with_pack = self._out_picking(picking_chain)
+ ship_with_pack.release_available_to_promise()
+ pack_with_pack = self._prev_picking(ship_with_pack)
+ pick_with_pack = self._prev_picking(pack_with_pack)
+ # Process pick pickings
+ self._deliver(pick_with_pack)
+ self._deliver(pick_no_pack)
+ # put pack moves in packages on pack_with_pack,
+ pack_moves = pack_with_pack.move_ids
+ pack_move1 = pack_moves.filtered(lambda m: m.product_id == self.product1)
+ pack_move2 = pack_moves.filtered(lambda m: m.product_id == self.product2)
+ self.put_in_pack(pack_move1)
+ self.put_in_pack(pack_move2)
+ # Process pack pickings
+ self._deliver(pack_with_pack)
+ self._deliver(pack_no_pack)
+ # unrelease both ship pickings at once
+ (ship_with_pack | ship_no_pack).unrelease()
+ cancel_pickings = self._get_cleanup_picking()
+ # We should have 1 return picking only
+ self.assertEqual(len(cancel_pickings), 1)
+ # We should have 3 moves
+ cancel_moves = cancel_pickings.move_ids
+ self.assertEqual(len(cancel_moves), 3)
+ # We should have:
+ # - 1 move for product1 without pack
+ # - 1 move for product1 with pack
+ # - 1 move for product2 with pack
+ cancel_move1_no_pack = cancel_moves.filtered(
+ lambda m: m.product_id == self.product1 and not m.move_line_ids.package_id
+ )
+ cancel_move1_with_pack = cancel_moves.filtered(
+ lambda m: m.product_id == self.product1 and m.move_line_ids.package_id
+ )
+ cancel_move2_with_pack = cancel_moves.filtered(
+ lambda m: m.product_id == self.product2 and m.move_line_ids.package_id
+ )
+ self.assertTrue(cancel_move1_no_pack)
+ self.assertEqual(cancel_move1_no_pack.product_qty, 10)
+ self.assertTrue(cancel_move1_with_pack)
+ self.assertEqual(cancel_move1_with_pack.product_qty, 10)
+ self.assertTrue(cancel_move2_with_pack)
+ self.assertEqual(cancel_move2_with_pack.product_qty, 10)
+
+ def test_return_quantity_in_stock(self):
+ move_model = self.env["stock.move"]
+ pack_move = self.pack_picking.move_ids
+ # process pick and pack, so pack is done and returnable
+ self._deliver(self.pick_picking)
+ self._deliver(self.pack_picking)
+ # Using empty_recordsets doesn't raises an exception and doesn't create
+ # a return picking
+ empty_args = {}
+ move_model._return_quantity_in_stock(empty_args)
+ self.assertFalse(self._get_cleanup_picking())
+ # Adding a move with no quantity
+ empty_args = {pack_move.id: 0}
+ move_model._return_quantity_in_stock(empty_args)
+ self.assertFalse(self._get_cleanup_picking())
+ # Adding a quantity should create a return picking
+ valid_args = {pack_move.id: 5}
+ move_model._return_quantity_in_stock(valid_args)
+ return_picking = self._get_cleanup_picking()
+ self.assertEqual(return_picking.move_ids.product_qty, 5)
+
+ def test_unrelease_shipped(self):
+ self._deliver(self.pick_picking)
+ self._deliver(self.pack_picking)
+ self._deliver(self.ship_picking)
+ self.ship_picking.unrelease()
+ # Did nothing
+ self.assertEqual(self.ship_picking.state, "done")
+ self.assertEqual(self.pack_picking.state, "done")
+ self.assertEqual(self.pick_picking.state, "done")
diff --git a/stock_available_to_promise_release/views/stock_route_views.xml b/stock_available_to_promise_release/views/stock_route_views.xml
index 514953fee7f..8861bb75968 100644
--- a/stock_available_to_promise_release/views/stock_route_views.xml
+++ b/stock_available_to_promise_release/views/stock_route_views.xml
@@ -7,6 +7,7 @@
+
From 3e673133633a66a469002db6dbbb9ab30203eca6 Mon Sep 17 00:00:00 2001
From: Jacques-Etienne Baudoux
Date: Wed, 23 Apr 2025 14:44:16 +0200
Subject: [PATCH 027/357] stock_available_to_promise_release: fix test picking
priority
---
.../tests/test_reservation.py | 24 +++++++++----------
1 file changed, 11 insertions(+), 13 deletions(-)
diff --git a/stock_available_to_promise_release/tests/test_reservation.py b/stock_available_to_promise_release/tests/test_reservation.py
index 11891f61dba..85c378f9fbc 100644
--- a/stock_available_to_promise_release/tests/test_reservation.py
+++ b/stock_available_to_promise_release/tests/test_reservation.py
@@ -1131,13 +1131,14 @@ def test_mto_picking(self):
# TODO: test w/ multiple orders by priority
def test_picking_priority(self):
- self.wh.delivery_steps = "pick_pack_ship"
+ self.wh.delivery_steps = "pick_ship"
self.wh.delivery_route_id.write({"available_to_promise_defer_pull": True})
self._update_qty_in_location(self.loc_bin1, self.product1, 20.0)
pick = self._create_picking_chain(self.wh, [(self.product1, 20)])
pick.priority = "1"
pick.action_confirm()
pick.release_available_to_promise()
+ self.assertEqual(pick.priority, "1")
self.assertEqual(pick.move_ids.move_orig_ids.picking_id.priority, "1")
# from here we simulate a special processing flow where a priority
@@ -1148,23 +1149,20 @@ def test_picking_priority(self):
# partially process the picking
pick_pick = pick.move_ids.move_orig_ids.picking_id
pick_pick.move_ids.quantity_done = 3.0
- pick_pick._action_done()
+ pick_pick.with_context(
+ skip_immediate=True, skip_backorder=True
+ ).button_validate()
# process and validate the picking to create a backorder
pick.move_ids.quantity_done = 3.0
- pick.unrelease()
- pick._action_done()
-
- # force priority on the initial picks to simulate an inconsistency
- # this case should not happen but we observe it in real life without
- # knowing how it happens
- pick.priority = "1"
- pick_pick.priority = "1"
+ pick.picking_type_id.unrelease_on_backorder = True
+ pick.with_context(skip_immediate=True, skip_backorder=True).button_validate()
backorder = pick.backorder_ids
+ self.assertEqual(backorder.move_ids.need_release, True)
+ # the backorder should have kept the initial priority
+ self.assertEqual(backorder.priority, "1")
# change the priority on the backorder and release it
backorder.priority = "0"
- backorder.action_confirm()
- # force need release to True for the test
- backorder.move_ids.need_release = True
+ self.assertEqual(pick.move_ids.priority, "0")
backorder.release_available_to_promise()
# the backorder should keep the new priority
self.assertEqual(backorder.priority, "0")
From c76669e511b0836bbea34953cdb405aa262b5620 Mon Sep 17 00:00:00 2001
From: oca-ci
Date: Mon, 28 Apr 2025 16:01:16 +0000
Subject: [PATCH 028/357] [UPD] Update stock_available_to_promise_release.pot
---
.../stock_available_to_promise_release.pot | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/stock_available_to_promise_release/i18n/stock_available_to_promise_release.pot b/stock_available_to_promise_release/i18n/stock_available_to_promise_release.pot
index d94b4663f21..493952fe07c 100644
--- a/stock_available_to_promise_release/i18n/stock_available_to_promise_release.pot
+++ b/stock_available_to_promise_release/i18n/stock_available_to_promise_release.pot
@@ -154,6 +154,15 @@ msgid ""
"new moves will be added to a new delivery."
msgstr ""
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid ""
+"If checked, unreleasing the delivery may create a new inverse internal "
+"operation on the last done pulled transfer. Otherwise, you won't be able to "
+"unrelease as soon as one of the pulled transfer is done"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_picking_type__unrelease_on_backorder
msgid ""
@@ -315,6 +324,12 @@ msgstr ""
msgid "Release based on Available to Promise"
msgstr ""
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid "Reverse done transfer on cancellation"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model,name:stock_available_to_promise_release.model_stock_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
@@ -366,6 +381,13 @@ msgid ""
"%(move_names)s"
msgstr ""
+#. module: stock_available_to_promise_release
+#. odoo-python
+#: code:addons/stock_available_to_promise_release/models/stock_move.py:0
+#, python-format
+msgid "The operation %(picking_names)s is done and cannot be returned"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
msgid "The selected records will be released."
From 08171953c599f334f93005ca3c33dfd112aee1a7 Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Mon, 28 Apr 2025 16:12:31 +0000
Subject: [PATCH 029/357] [BOT] post-merge updates
---
README.md | 2 +-
stock_available_to_promise_release/README.rst | 2 +-
stock_available_to_promise_release/__manifest__.py | 2 +-
.../static/description/index.html | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index e6a6de90ba2..8f8d712b033 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ addon | version | maintainers | summary
[shopfloor_rest_log](shopfloor_rest_log/) | 16.0.1.0.0 | [](https://github.com/simahawk) | Integrate rest_log into Shopfloor app
[shopfloor_workstation](shopfloor_workstation/) | 16.0.1.0.0 | | Manage warehouse workstation with barcode scanners
[shopfloor_workstation_mobile](shopfloor_workstation_mobile/) | 16.0.1.0.0 | | Shopfloor mobile app integration for workstation
-[stock_available_to_promise_release](stock_available_to_promise_release/) | 16.0.3.6.2 | | Release Operations based on available to promise
+[stock_available_to_promise_release](stock_available_to_promise_release/) | 16.0.3.7.0 | | Release Operations based on available to promise
[stock_available_to_promise_release_block](stock_available_to_promise_release_block/) | 16.0.1.1.1 | | Block Release of Operations
[stock_available_to_promise_release_exclude_location](stock_available_to_promise_release_exclude_location/) | 16.0.1.0.0 | | Exclude locations from available stock
[stock_dynamic_routing](stock_dynamic_routing/) | 16.0.1.0.2 | | Dynamic routing of stock moves
diff --git a/stock_available_to_promise_release/README.rst b/stock_available_to_promise_release/README.rst
index bc3c5b06be5..a25557b3030 100644
--- a/stock_available_to_promise_release/README.rst
+++ b/stock_available_to_promise_release/README.rst
@@ -7,7 +7,7 @@ Stock Available to Promise Release
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
- !! source digest: sha256:7dbd35643eddc66766843961912a08ae586df66939dbdc9472d365d36da415b8
+ !! source digest: sha256:a1337e83c6332c7cfddb1c2c787bcc88bf6bb309e9c45ff10492d36ff8c74fe3
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
diff --git a/stock_available_to_promise_release/__manifest__.py b/stock_available_to_promise_release/__manifest__.py
index 9371b2e0cd0..75f42be939a 100644
--- a/stock_available_to_promise_release/__manifest__.py
+++ b/stock_available_to_promise_release/__manifest__.py
@@ -3,7 +3,7 @@
{
"name": "Stock Available to Promise Release",
- "version": "16.0.3.6.2",
+ "version": "16.0.3.7.0",
"summary": "Release Operations based on available to promise",
"author": "Camptocamp, BCIM, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/wms",
diff --git a/stock_available_to_promise_release/static/description/index.html b/stock_available_to_promise_release/static/description/index.html
index 74ba04c6572..a4a8e43214b 100644
--- a/stock_available_to_promise_release/static/description/index.html
+++ b/stock_available_to_promise_release/static/description/index.html
@@ -367,7 +367,7 @@ Stock Available to Promise Release
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
-!! source digest: sha256:7dbd35643eddc66766843961912a08ae586df66939dbdc9472d365d36da415b8
+!! source digest: sha256:a1337e83c6332c7cfddb1c2c787bcc88bf6bb309e9c45ff10492d36ff8c74fe3
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Currently the reservation is performed by adding reserved quantities on quants,
From 4d718210357b9a6fa168c3da6e6d2c4758ad85d3 Mon Sep 17 00:00:00 2001
From: Weblate
Date: Mon, 28 Apr 2025 16:12:40 +0000
Subject: [PATCH 030/357] Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.
Translation: wms-16.0/wms-16.0-stock_available_to_promise_release
Translate-URL: https://translation.odoo-community.org/projects/wms-16-0/wms-16-0-stock_available_to_promise_release/
---
stock_available_to_promise_release/i18n/es.po | 22 +++++++++++++++++++
.../i18n/es_AR.po | 22 +++++++++++++++++++
stock_available_to_promise_release/i18n/fr.po | 22 +++++++++++++++++++
stock_available_to_promise_release/i18n/hr.po | 22 +++++++++++++++++++
stock_available_to_promise_release/i18n/it.po | 22 +++++++++++++++++++
5 files changed, 110 insertions(+)
diff --git a/stock_available_to_promise_release/i18n/es.po b/stock_available_to_promise_release/i18n/es.po
index a2309506b01..027efd538a7 100644
--- a/stock_available_to_promise_release/i18n/es.po
+++ b/stock_available_to_promise_release/i18n/es.po
@@ -175,6 +175,15 @@ msgstr ""
"más movimientos. Por ejemplo, si se añaden líneas en el pedido de cliente de "
"origen, los nuevos movimientos se añadirán a una nueva entrega."
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid ""
+"If checked, unreleasing the delivery may create a new inverse internal "
+"operation on the last done pulled transfer. Otherwise, you won't be able to "
+"unrelease as soon as one of the pulled transfer is done"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_picking_type__unrelease_on_backorder
msgid ""
@@ -344,6 +353,12 @@ msgstr "Liberar asignaciones de transferencias"
msgid "Release based on Available to Promise"
msgstr "Lanzamiento basado en disponibilidad para prometer"
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid "Reverse done transfer on cancellation"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model,name:stock_available_to_promise_release.model_stock_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
@@ -399,6 +414,13 @@ msgstr ""
"Los siguientes movimientos no se han publicado:\n"
"%(move_names)s"
+#. module: stock_available_to_promise_release
+#. odoo-python
+#: code:addons/stock_available_to_promise_release/models/stock_move.py:0
+#, python-format
+msgid "The operation %(picking_names)s is done and cannot be returned"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
msgid "The selected records will be released."
diff --git a/stock_available_to_promise_release/i18n/es_AR.po b/stock_available_to_promise_release/i18n/es_AR.po
index 7dfe4413025..a74583bdb21 100644
--- a/stock_available_to_promise_release/i18n/es_AR.po
+++ b/stock_available_to_promise_release/i18n/es_AR.po
@@ -174,6 +174,15 @@ msgid ""
"new moves will be added to a new delivery."
msgstr ""
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid ""
+"If checked, unreleasing the delivery may create a new inverse internal "
+"operation on the last done pulled transfer. Otherwise, you won't be able to "
+"unrelease as soon as one of the pulled transfer is done"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_picking_type__unrelease_on_backorder
msgid ""
@@ -337,6 +346,12 @@ msgstr "Liberar Asignaciones de Transferencias"
msgid "Release based on Available to Promise"
msgstr "Liberación basada en Disponibilidad a Prometer"
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid "Reverse done transfer on cancellation"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model,name:stock_available_to_promise_release.model_stock_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
@@ -388,6 +403,13 @@ msgid ""
"%(move_names)s"
msgstr ""
+#. module: stock_available_to_promise_release
+#. odoo-python
+#: code:addons/stock_available_to_promise_release/models/stock_move.py:0
+#, python-format
+msgid "The operation %(picking_names)s is done and cannot be returned"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
msgid "The selected records will be released."
diff --git a/stock_available_to_promise_release/i18n/fr.po b/stock_available_to_promise_release/i18n/fr.po
index f36bf4d5727..ec36be7baa5 100644
--- a/stock_available_to_promise_release/i18n/fr.po
+++ b/stock_available_to_promise_release/i18n/fr.po
@@ -175,6 +175,15 @@ msgstr ""
"des lignes dans la commande de vente d'origine, les nouveaux mouvements "
"seront ajoutés à une nouvelle livraison."
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid ""
+"If checked, unreleasing the delivery may create a new inverse internal "
+"operation on the last done pulled transfer. Otherwise, you won't be able to "
+"unrelease as soon as one of the pulled transfer is done"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_picking_type__unrelease_on_backorder
msgid ""
@@ -343,6 +352,12 @@ msgstr ""
msgid "Release based on Available to Promise"
msgstr ""
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid "Reverse done transfer on cancellation"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model,name:stock_available_to_promise_release.model_stock_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
@@ -398,6 +413,13 @@ msgstr ""
"Les mouvements suivants ont été remis en attente de libération : \n"
"%(move_names)s"
+#. module: stock_available_to_promise_release
+#. odoo-python
+#: code:addons/stock_available_to_promise_release/models/stock_move.py:0
+#, python-format
+msgid "The operation %(picking_names)s is done and cannot be returned"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
msgid "The selected records will be released."
diff --git a/stock_available_to_promise_release/i18n/hr.po b/stock_available_to_promise_release/i18n/hr.po
index 6f011d613ce..910342ec56f 100644
--- a/stock_available_to_promise_release/i18n/hr.po
+++ b/stock_available_to_promise_release/i18n/hr.po
@@ -166,6 +166,15 @@ msgid ""
"new moves will be added to a new delivery."
msgstr ""
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid ""
+"If checked, unreleasing the delivery may create a new inverse internal "
+"operation on the last done pulled transfer. Otherwise, you won't be able to "
+"unrelease as soon as one of the pulled transfer is done"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_picking_type__unrelease_on_backorder
msgid ""
@@ -329,6 +338,12 @@ msgstr "Otpusti alokacije prijenosa"
msgid "Release based on Available to Promise"
msgstr "Otpusti bazirano na raspoloživom za obećati"
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid "Reverse done transfer on cancellation"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model,name:stock_available_to_promise_release.model_stock_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
@@ -382,6 +397,13 @@ msgid ""
"%(move_names)s"
msgstr ""
+#. module: stock_available_to_promise_release
+#. odoo-python
+#: code:addons/stock_available_to_promise_release/models/stock_move.py:0
+#, python-format
+msgid "The operation %(picking_names)s is done and cannot be returned"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
msgid "The selected records will be released."
diff --git a/stock_available_to_promise_release/i18n/it.po b/stock_available_to_promise_release/i18n/it.po
index cff64aef092..fccc6ec5f44 100644
--- a/stock_available_to_promise_release/i18n/it.po
+++ b/stock_available_to_promise_release/i18n/it.po
@@ -175,6 +175,15 @@ msgstr ""
"nell'ordine di vendita originale, i nuovi movimenti verranno aggiunti a una "
"nuova consegna."
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid ""
+"If checked, unreleasing the delivery may create a new inverse internal "
+"operation on the last done pulled transfer. Otherwise, you won't be able to "
+"unrelease as soon as one of the pulled transfer is done"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model.fields,help:stock_available_to_promise_release.field_stock_picking_type__unrelease_on_backorder
msgid ""
@@ -345,6 +354,12 @@ msgstr "Rilascio assegnazioni trasferimenti"
msgid "Release based on Available to Promise"
msgstr "Rilascio in base alla disponibilità alle promesse"
+#. module: stock_available_to_promise_release
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_route__allow_unrelease_return_done_move
+#: model:ir.model.fields,field_description:stock_available_to_promise_release.field_stock_rule__allow_unrelease_return_done_move
+msgid "Reverse done transfer on cancellation"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model:ir.model,name:stock_available_to_promise_release.model_stock_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
@@ -400,6 +415,13 @@ msgstr ""
"I segenti movimenti sono stati trattenuti: \n"
"%(move_names)s"
+#. module: stock_available_to_promise_release
+#. odoo-python
+#: code:addons/stock_available_to_promise_release/models/stock_move.py:0
+#, python-format
+msgid "The operation %(picking_names)s is done and cannot be returned"
+msgstr ""
+
#. module: stock_available_to_promise_release
#: model_terms:ir.ui.view,arch_db:stock_available_to_promise_release.view_stock_release_form
msgid "The selected records will be released."
From fd41b01d86ea88b34e2f0259fa27723edf4f1237 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Alix?=
Date: Thu, 17 Apr 2025 17:25:10 +0200
Subject: [PATCH 031/357] stock_storage_type: define 'Allow New Product' rules
with conditions
The 'Allow New Product' rules that were defined on storage capacity
lines have been moved to a new data model
`stock.storage.category.allow_new_product`.
On these rules, conditions are set, and all of them have to be `True` in
order to set the expected 'Allow New Product' value.
The conditions are defined in another new data model
(`stock.storage.category.allow_new_product.cond`), so they can be reused
by different 'Allow New Product' rules.
A condition can use different inputs to do its evaluation (product,
package type, quant...), allowing a more advanced configuration than
before.
A `stock.storage.condition.mixin` has been added so both the new data
model `stock.storage.category.allow_new_product.cond` and
`stock.storage.location.sequence.cond` inherit from it.
---
stock_storage_type/__manifest__.py | 5 +-
.../demo/stock_storage_category.xml | 13 ++
...torage_category_allow_new_product_cond.xml | 20 +++
.../demo/stock_storage_category_capacity.xml | 1 -
.../migrations/16.0.2.0.0/post-migrate.py | 63 ++++++++
stock_storage_type/models/__init__.py | 4 +-
stock_storage_type/models/stock_location.py | 117 ++++++--------
.../models/stock_package_type.py | 8 +-
stock_storage_type/models/stock_quant.py | 149 ++++++++----------
.../models/stock_quant_package.py | 6 +-
.../models/stock_storage_category.py | 120 +++++++++++++-
...tock_storage_category_allow_new_product.py | 31 ++++
...storage_category_allow_new_product_cond.py | 138 ++++++++++++++++
.../models/stock_storage_category_capacity.py | 111 -------------
.../models/stock_storage_condition_mixin.py | 65 ++++++++
.../models/stock_storage_location_sequence.py | 15 +-
.../stock_storage_location_sequence_cond.py | 45 +-----
stock_storage_type/readme/CONFIGURATION.rst | 18 +--
stock_storage_type/readme/DESCRIPTION.rst | 46 ++----
.../security/ir.model.access.csv | 4 +
stock_storage_type/tests/__init__.py | 1 +
.../tests/test_auto_assign_storage_type.py | 2 +-
.../tests/test_stock_location.py | 11 +-
...test_storage_category_allow_new_product.py | 111 +++++++++++++
stock_storage_type/tests/test_storage_type.py | 21 +--
.../tests/test_storage_type_move.py | 25 ++-
.../test_storage_type_putaway_strategy.py | 27 +++-
.../views/stock_package_type.xml | 6 -
.../views/stock_storage_category.xml | 24 ++-
...torage_category_allow_new_product_cond.xml | 54 +++++++
.../views/stock_storage_category_capacity.xml | 18 ---
.../views/storage_type_menus.xml | 7 +
32 files changed, 866 insertions(+), 420 deletions(-)
create mode 100644 stock_storage_type/demo/stock_storage_category_allow_new_product_cond.xml
create mode 100644 stock_storage_type/migrations/16.0.2.0.0/post-migrate.py
create mode 100644 stock_storage_type/models/stock_storage_category_allow_new_product.py
create mode 100644 stock_storage_type/models/stock_storage_category_allow_new_product_cond.py
delete mode 100644 stock_storage_type/models/stock_storage_category_capacity.py
create mode 100644 stock_storage_type/models/stock_storage_condition_mixin.py
create mode 100644 stock_storage_type/tests/test_storage_category_allow_new_product.py
create mode 100644 stock_storage_type/views/stock_storage_category_allow_new_product_cond.xml
delete mode 100644 stock_storage_type/views/stock_storage_category_capacity.xml
diff --git a/stock_storage_type/__manifest__.py b/stock_storage_type/__manifest__.py
index 48b4a0e18ae..4e1510bfa12 100644
--- a/stock_storage_type/__manifest__.py
+++ b/stock_storage_type/__manifest__.py
@@ -4,7 +4,7 @@
{
"name": "Stock Storage Type",
"summary": "Manage packages and locations storage types",
- "version": "16.0.1.1.0",
+ "version": "16.0.2.0.0",
"development_status": "Beta",
"category": "Warehouse Management",
"website": "https://github.com/OCA/wms",
@@ -24,7 +24,7 @@
"views/product_template.xml",
"views/stock_location.xml",
"views/stock_storage_category.xml",
- "views/stock_storage_category_capacity.xml",
+ "views/stock_storage_category_allow_new_product_cond.xml",
"views/stock_package_level.xml",
"views/stock_package_type.xml",
"views/stock_storage_location_sequence.xml",
@@ -33,6 +33,7 @@
],
"demo": [
"demo/stock_package_type.xml",
+ "demo/stock_storage_category_allow_new_product_cond.xml",
"demo/stock_storage_category.xml",
"demo/stock_storage_category_capacity.xml",
"demo/product_packaging.xml",
diff --git a/stock_storage_type/demo/stock_storage_category.xml b/stock_storage_type/demo/stock_storage_category.xml
index 174b1166c17..503e24ce0ab 100644
--- a/stock_storage_type/demo/stock_storage_category.xml
+++ b/stock_storage_type/demo/stock_storage_category.xml
@@ -5,6 +5,19 @@
Pallets
+
+
+
+
+ empty
+
+
Cardboxes
diff --git a/stock_storage_type/demo/stock_storage_category_allow_new_product_cond.xml b/stock_storage_type/demo/stock_storage_category_allow_new_product_cond.xml
new file mode 100644
index 00000000000..33fec54528d
--- /dev/null
+++ b/stock_storage_type/demo/stock_storage_category_allow_new_product_cond.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ Package type is 'Pallets'
+
+
+
+
+
+
diff --git a/stock_storage_type/demo/stock_storage_category_capacity.xml b/stock_storage_type/demo/stock_storage_category_capacity.xml
index 5a3a536582f..f2fdaeee0b5 100644
--- a/stock_storage_type/demo/stock_storage_category_capacity.xml
+++ b/stock_storage_type/demo/stock_storage_category_capacity.xml
@@ -4,7 +4,6 @@
- empty
.allow_new_product' values to "
+ "category allow_new_product rules..."
+ )
+ query = """
+ SELECT sscc.id, storage_category_id, sscc.allow_new_product, package_type_id
+ FROM stock_storage_category_capacity sscc
+ LEFT JOIN stock_storage_category ssc
+ ON sscc.storage_category_id = ssc.id
+ WHERE sscc.allow_new_product != ssc.allow_new_product;
+ """
+ env.cr.execute(query)
+ capacities = env.cr.dictfetchall()
+ condition_model = env["stock.storage.category.allow_new_product.cond"]
+ for capacity in capacities:
+ _logger.info("row = %s", capacity)
+ package_type = env["stock.package.type"].browse(capacity["package_type_id"])
+ package_type_name = package_type.name if package_type else "Any package type"
+ condition_name = f"[MIG] {package_type_name}"
+ condition = condition_model.search([("name", "=", condition_name)], limit=1)
+ if not condition:
+ if package_type:
+ code_snippet = f"""
+result = False
+if package_type and package_type.id == {package_type.id}:
+ result = True
+ """
+ else:
+ code_snippet = """
+result = False
+if not package_type:
+ result = True
+ """
+ vals = {
+ "name": condition_name,
+ "code_snippet": code_snippet,
+ }
+ condition = condition_model.create(vals)
+ # Bind the condition with the category through a allow_new_product rule
+ category = env["stock.storage.category"].browse(capacity["storage_category_id"])
+ vals = {
+ "allow_new_product": capacity["allow_new_product"],
+ "condition_ids": [fields.Command.link(condition.id)],
+ }
+ category.write({"allow_new_product_ids": [fields.Command.create(vals)]})
diff --git a/stock_storage_type/models/__init__.py b/stock_storage_type/models/__init__.py
index ef4b4162a17..2c5f31919dd 100644
--- a/stock_storage_type/models/__init__.py
+++ b/stock_storage_type/models/__init__.py
@@ -5,8 +5,10 @@
stock_package_type,
stock_quant,
stock_quant_package,
+ stock_storage_condition_mixin,
stock_storage_category,
- stock_storage_category_capacity,
+ stock_storage_category_allow_new_product,
+ stock_storage_category_allow_new_product_cond,
stock_storage_location_sequence,
stock_storage_location_sequence_cond,
)
diff --git a/stock_storage_type/models/stock_location.py b/stock_storage_type/models/stock_location.py
index 9672dcce608..72d2de071cd 100644
--- a/stock_storage_type/models/stock_location.py
+++ b/stock_storage_type/models/stock_location.py
@@ -46,7 +46,7 @@ class StockLocation(models.Model):
"Ordered Children Locations: when moved to this "
"location, a suitable location will be searched in its children "
"locations according to the restrictions defined on their "
- "respective location storage types.",
+ "respective storage category.",
)
package_type_putaway_sequence = fields.Integer(
string="Putaway Sequence",
@@ -64,7 +64,7 @@ class StockLocation(models.Model):
help="technical field: True if the location is empty "
"and there is no pending incoming products in the location. "
" Computed only if the location needs to check for emptiness "
- '(has an "only empty" location storage type).',
+ '(has an "only empty" policy).',
recursive=True,
)
# TODO: Maybe renaming these fields as there are already such fields
@@ -158,7 +158,7 @@ def init(self): # pylint: disable=missing-return
@api.depends(
"usage",
"computed_storage_category_id.allow_new_product",
- "computed_storage_category_id.capacity_ids.allow_new_product",
+ "computed_storage_category_id.allow_new_product_ids.allow_new_product",
)
def _compute_do_not_mix_lots(self):
"""
@@ -167,18 +167,16 @@ def _compute_do_not_mix_lots(self):
- one of its Storage Capacities value
"""
for rec in self:
+ rules = rec.computed_storage_category_id.allow_new_product_ids
rec.do_not_mix_lots = rec.usage == "internal" and (
- any(
- storage_type.allow_new_product == "same_lot"
- for storage_type in rec.computed_storage_category_id.capacity_ids
- )
+ any(rule.allow_new_product == "same_lot" for rule in rules)
or rec.computed_storage_category_id.allow_new_product == "same_lot"
)
@api.depends(
"usage",
"computed_storage_category_id.allow_new_product",
- "computed_storage_category_id.capacity_ids.allow_new_product",
+ "computed_storage_category_id.allow_new_product_ids.allow_new_product",
)
def _compute_only_empty(self):
"""
@@ -187,18 +185,16 @@ def _compute_only_empty(self):
- one of its Storage Capacities value
"""
for rec in self:
+ rules = rec.computed_storage_category_id.allow_new_product_ids
rec.only_empty = rec.usage == "internal" and (
- any(
- storage_type.allow_new_product == "empty"
- for storage_type in rec.computed_storage_category_id.capacity_ids
- )
+ any(rule.allow_new_product == "empty" for rule in rules)
or rec.computed_storage_category_id.allow_new_product == "empty"
)
@api.depends(
"usage",
"computed_storage_category_id.allow_new_product",
- "computed_storage_category_id.capacity_ids.allow_new_product",
+ "computed_storage_category_id.allow_new_product_ids.allow_new_product",
)
def _compute_do_not_mix_products(self):
"""
@@ -207,11 +203,9 @@ def _compute_do_not_mix_products(self):
- one of its Storage Capacities value
"""
for rec in self:
+ rules = rec.computed_storage_category_id.allow_new_product_ids
rec.do_not_mix_products = rec.usage == "internal" and (
- any(
- storage_type.allow_new_product in ("same", "same_lot")
- for storage_type in rec.computed_storage_category_id.capacity_ids
- )
+ any(rule.allow_new_product in ("same", "same_lot") for rule in rules)
or rec.computed_storage_category_id.allow_new_product
in ("same", "same_lot")
)
@@ -513,30 +507,29 @@ def select_first_allowed_location(self, package_type, quants, products):
allowed = self.select_allowed_locations(package_type, quants, products, limit=1)
return allowed
- def _domain_location_storage_type_constraints(self, package_type, quants, products):
- """Compute the domain for the location storage type which match the package
- storage type
+ def _domain_storage_category_constraints(self, package_type, quants, products):
+ """Compute the domain for the storage category which matches the package type.
- This method also checks the "capacity" constraints (height and weight)
+ This method also checks the category constraints (height and weight)
"""
- # There can be multiple location storage types for a given
+ # There can be multiple storage capacities for a given
# location, so we need to filter on the ones relative to the package
# we consider.
- Capacity = self.env["stock.storage.category.capacity"]
- compatible_location_storage_types = Capacity.search(
+ Category = self.env["stock.storage.category"]
+ compatible_categories = Category.search(
[("computed_location_ids", "in", self.ids)]
)
- pertinent_loc_storagetype_domain = [
- ("id", "in", compatible_location_storage_types.ids),
- ("package_type_id", "=", package_type.id),
+ pertinent_category_domain = [
+ ("id", "in", compatible_categories.ids),
+ ("capacity_ids.package_type_id", "=", package_type.id),
]
if quants.package_id.height:
- pertinent_loc_storagetype_domain += [
+ pertinent_category_domain += [
"|",
- ("storage_category_id.max_height_in_m", "=", 0),
+ ("max_height_in_m", "=", 0),
(
- "storage_category_id.max_height_in_m",
+ "max_height_in_m",
">=",
quants.package_id.height_in_m,
),
@@ -546,23 +539,23 @@ def _domain_location_storage_type_constraints(self, package_type, quants, produc
or quants.package_id.estimated_pack_weight_kg
)
if package_weight_kg:
- pertinent_loc_storagetype_domain += [
+ pertinent_category_domain += [
"|",
- ("storage_category_id.max_weight_in_kg", "=", 0),
- ("storage_category_id.max_weight_in_kg", ">=", package_weight_kg),
+ ("max_weight_in_kg", "=", 0),
+ ("max_weight_in_kg", ">=", package_weight_kg),
]
_logger.debug(
- "pertinent storage type domain: %s", pertinent_loc_storagetype_domain
+ "pertinent storage category domain: %s", pertinent_category_domain
)
- return pertinent_loc_storagetype_domain
+ return pertinent_category_domain
- def _allowed_locations_for_location_storage_types(
- self, location_storage_types, quants, products
+ def _allowed_locations_for_storage_categories(
+ self, categories, quants, products, package_type
):
valid_location_ids = set()
- for loc_storage_type in location_storage_types:
- location_domain = loc_storage_type._domain_location_storage_type(
- self, quants, products
+ for category in categories:
+ location_domain = category._domain_location_storage_category(
+ self, quants, products, package_type
)
_logger.debug("pertinent location domain: %s", location_domain)
locations = self.search(location_domain)
@@ -581,7 +574,7 @@ def _select_final_valid_putaway_locations(self, limit=None):
return self[:limit]
def select_allowed_locations(self, package_type, quants, products, limit=None):
- """Filter allowed locations for a storage type
+ """Filter allowed locations for a package type.
``self`` contains locations already ordered according to the
putaway strategy, so beware of the return that must keep the
@@ -589,45 +582,31 @@ def select_allowed_locations(self, package_type, quants, products, limit=None):
"""
# We have package who may be placed in a stock.location
#
- # 1. On the stock.location there are location_storage_type and on the
- # packages there are package_storage_type. Between both, there's a m2m
- # who says which package ST can be placed in which location ST
+ # 1. On the location there is a storage category that defines which
+ # package type is allowed. This is given by the storage capacities.
#
- # 2. On a location_ST there are some additional restrictions: a -
- # capacity (volume / height / weight) and b - properties (boolean
+ # 2. On a storage category there are some additional restrictions:
+ # a - capacity (volume / height / weight) and b - properties (boolean
# flags: only empty, don't mix lots, don't mix products)
- Capacity = self.env["stock.storage.category.capacity"]
_logger.debug(
- "select allowed location for package storage type %s (q=%s, p=%s)",
+ "select allowed location for package type %s (q=%s, p=%s)",
package_type.name,
quants,
products.mapped("name"),
)
- # 1: filter locations on compatible storage type
- compatible_locations = self.search(
- [
- ("id", "in", self.ids),
- (
- "computed_storage_category_id.capacity_ids",
- "in",
- package_type.storage_category_capacity_ids.ids,
- ),
- ]
- )
- pertinent_loc_s_t_domain = (
- compatible_locations._domain_location_storage_type_constraints(
- package_type, quants, products
- )
+ # 1: filter pertinent storage categories
+ pertinent_category_domain = self._domain_storage_category_constraints(
+ package_type, quants, products
)
- pertinent_loc_storage_types = Capacity.search(pertinent_loc_s_t_domain)
+ pertinent_categories = self.env["stock.storage.category"].search(
+ pertinent_category_domain
+ )
- # now loop over the pertinent location storage types (there should be
+ # now loop over the pertinent categories (there should be
# few of them) and check for properties to find suitable locations
- valid_locations = (
- compatible_locations._allowed_locations_for_location_storage_types(
- pertinent_loc_storage_types, quants, products
- )
+ valid_locations = self._allowed_locations_for_storage_categories(
+ pertinent_categories, quants, products, package_type
)
valid_locations = self._order_allowed_locations(valid_locations)
diff --git a/stock_storage_type/models/stock_package_type.py b/stock_storage_type/models/stock_package_type.py
index ab93b299c33..d099958cd71 100644
--- a/stock_storage_type/models/stock_package_type.py
+++ b/stock_storage_type/models/stock_package_type.py
@@ -16,7 +16,7 @@ class StockPackageType(models.Model):
storage_type_message = fields.Html(compute="_compute_storage_type_message")
height_required = fields.Boolean(
string="Height required for packages",
- help=("Height is mandatory for packages configured with this storage type."),
+ help=("Height is mandatory for packages configured with this package type."),
default=False,
)
barcode = fields.Char(copy=False)
@@ -36,10 +36,10 @@ def _compute_storage_type_message(self):
if sl == storage_locations[-1]:
last = True
formatted_storage_locations_msgs.append(
- sl._format_package_storage_type_message(last=last)
+ sl._format_package_type_message(last=last)
)
msg = _(
- "When a package with storage type {name} is put away, the "
+ "When a package with type {name} is put away, the "
"strategy will look for an allowed location in the "
"following locations:
"
"{message}
"
@@ -56,7 +56,7 @@ def _compute_storage_type_message(self):
msg = _(
'The "Put-Away sequence" '
"must be defined in order to put away packages using "
- "this package storage type ({storage})."
+ "this package type ({storage})."
).format(storage=package_type.name)
package_type.storage_type_message = msg
diff --git a/stock_storage_type/models/stock_quant.py b/stock_storage_type/models/stock_quant.py
index ce08ee4ef23..effebd99c38 100644
--- a/stock_storage_type/models/stock_quant.py
+++ b/stock_storage_type/models/stock_quant.py
@@ -30,7 +30,6 @@ def _check_storage_capacities(self):
"Location {location}"
).format(storage=package_type.name, location=location.name)
)
- allowed = False
package_weight_kg = (
quant.package_id.pack_weight_in_kg
or quant.package_id.estimated_pack_weight_kg
@@ -47,94 +46,80 @@ def _check_storage_capacities(self):
)
products_in_location = other_quants_in_location.mapped("product_id")
lots_in_location = other_quants_in_location.mapped("lot_id")
- capacity_fails = []
- for capacity in allowed_capacities:
- # Check content constraints
- if capacity.allow_new_product == "empty" and other_quants_in_location:
- capacity_fails.append(
- _(
- "Storage Capacity {storage_capacity} is flagged "
- "'only empty'"
- " with other quants in location."
- ).format(storage_capacity=capacity.display_name)
- )
- continue
- if capacity.allow_new_product == "same" and (
- len(package_products) > 1
- or len(products_in_location) >= 1
- and package_products != products_in_location
- ):
- capacity_fails.append(
- _(
- "Storage Capacity {storage_capacity} is flagged 'do not mix"
- " products' but there are other products in "
- "location."
- ).format(storage_capacity=capacity.display_name)
- )
- continue
- if capacity.allow_new_product == "same_lot" and (
- len(package_lots) > 1
- or len(lots_in_location) >= 1
- and package_lots != lots_in_location
- ):
- capacity_fails.append(
- _(
- "Storage Capacity {storage_capacity} is flagged 'do not mix"
- " lots' but there are other lots in "
- "location."
- ).format(storage_capacity=capacity.display_name)
- )
- continue
- # Check size constraint
- if (
- capacity.storage_category_id.max_height_in_m
- and quant.package_id.height_in_m
- > capacity.storage_category_id.max_height_in_m
- ):
- capacity_fails.append(
- _(
- "Storage Category {storage_category} defines "
- "max height of {max_h} but the package is bigger: "
- "{height}."
- ).format(
- storage_category=capacity.storage_category_id.display_name,
- max_h=capacity.storage_category_id.max_height_in_m,
- height=quant.package_id.height_in_m,
- )
- )
- continue
- if (
- capacity.storage_category_id.max_weight_in_kg
- and package_weight_kg
- > capacity.storage_category_id.max_weight_in_kg
- ):
- capacity_fails.append(
- _(
- "Storage Category {storage_category} defines "
- "max weight of {max_w} but the package is heavier: "
- "{weight_kg}."
- ).format(
- storage_category=capacity.storage_category_id.display_name,
- max_w=capacity.storage_category_id.max_weight_in_kg,
- weight_kg=package_weight_kg,
- )
- )
- continue
- # If we get here, it means there is a location storage type
- # allowing the package into the location
- allowed = True
- break
- if not allowed:
+ error = None
+ category = location.computed_storage_category_id
+ allow_new_product = category.get_allow_new_product(
+ product=quant.product_id,
+ package_type=package_type,
+ package=quant.package_id,
+ quants=quant,
+ )
+ # Check content constraints
+ if allow_new_product == "empty" and other_quants_in_location:
+ error = _(
+ "Storage Category {category} is flagged "
+ "'only empty' with other quants in location."
+ ).format(category=category.display_name)
+ elif allow_new_product == "same" and (
+ len(package_products) > 1
+ or len(products_in_location) >= 1
+ and package_products != products_in_location
+ ):
+ error = _(
+ "Storage Category {category} is flagged 'do not mix"
+ " products' but there are other products in "
+ "location."
+ ).format(category=category.display_name)
+ elif allow_new_product == "same_lot" and (
+ len(package_lots) > 1
+ or len(lots_in_location) >= 1
+ and package_lots != lots_in_location
+ ):
+ error = _(
+ "Storage Category {category} is flagged 'do not mix"
+ " lots' but there are other lots in "
+ "location."
+ ).format(category=category.display_name)
+ # Check size constraint
+ elif (
+ category.max_height_in_m
+ and quant.package_id.height_in_m > category.max_height_in_m
+ ):
+ error = _(
+ "Storage Category {category} defines "
+ "max height of {max_h} but the package is bigger: "
+ "{height}."
+ ).format(
+ category=category.display_name,
+ max_h=category.max_height_in_m,
+ height=quant.package_id.height_in_m,
+ )
+ elif (
+ category.max_weight_in_kg
+ and package_weight_kg > category.max_weight_in_kg
+ ):
+ error = _(
+ "Storage Category {category} defines "
+ "max weight of {max_w} but the package is heavier: "
+ "{weight_kg}."
+ ).format(
+ category=category.display_name,
+ max_w=category.max_weight_in_kg,
+ weight_kg=package_weight_kg,
+ )
+ # If we get here, it means there is a storage category
+ # allowing the package into the location
+ if error:
raise ValidationError(
_(
"Package {package} is not allowed into location {location},"
- " because there isn't any storage capacity that allows"
- " package type {type} into it:\n\n{fails}"
+ " because there isn't any rules that allows"
+ " package type {type} into it:\n\n{error}"
).format(
package=quant.package_id.name,
location=location.complete_name,
type=package_type.name,
- fails="\n".join(capacity_fails),
+ error=error,
)
)
diff --git a/stock_storage_type/models/stock_quant_package.py b/stock_storage_type/models/stock_quant_package.py
index 3967e8ef59a..f7b642c7fad 100644
--- a/stock_storage_type/models/stock_quant_package.py
+++ b/stock_storage_type/models/stock_quant_package.py
@@ -51,8 +51,8 @@ def auto_assign_packaging(self):
res = super().auto_assign_packaging()
for package in self:
if not package.package_type_id:
- # if no storage type could be set by auto assign,
- # fallback on the default product's storage type (if any)
+ # if no package type could be set by auto assign,
+ # fallback on the default product's package type (if any)
package._sync_package_type_from_single_product()
return res
@@ -71,7 +71,7 @@ def write(self, vals):
def _sync_package_type_from_packaging(self):
for package in self:
if package.package_type_id:
- # Do not set package storage type for delivery packages
+ # Do not set package type for delivery packages
# to not trigger constraint like height requirement
# (we are delivering them, not storing them)
continue
diff --git a/stock_storage_type/models/stock_storage_category.py b/stock_storage_type/models/stock_storage_category.py
index 63b794c38f5..cb69e592a60 100644
--- a/stock_storage_type/models/stock_storage_category.py
+++ b/stock_storage_type/models/stock_storage_category.py
@@ -15,6 +15,11 @@ class StockStorageCategory(models.Model):
computed_location_ids = fields.One2many(
comodel_name="stock.location", inverse_name="computed_storage_category_id"
)
+ allow_new_product_ids = fields.One2many(
+ comodel_name="stock.storage.category.allow_new_product",
+ inverse_name="storage_category_id",
+ string="Allow New Product Rules",
+ )
# TODO: Move these fields in another module ?
max_height = fields.Float(
@@ -51,7 +56,6 @@ class StockStorageCategory(models.Model):
compute="_compute_max_weight_in_kg",
store=True,
)
-
length_uom_id = fields.Many2one(
# Same as product.packing
"uom.uom",
@@ -64,6 +68,10 @@ class StockStorageCategory(models.Model):
"product.template"
]._get_length_uom_id_from_ir_config_parameter(),
)
+ has_restrictions = fields.Boolean(
+ compute="_compute_has_restrictions",
+ help="Technical: This is used to check if we need to display warning message",
+ )
_sql_constraints = [
(
@@ -92,3 +100,113 @@ def _compute_max_weight_in_kg(self):
to_unit=uom_kg,
round=False,
)
+
+ @api.depends(
+ "allow_new_product",
+ "allow_new_product_ids.allow_new_product",
+ "max_height",
+ "max_weight",
+ )
+ def _compute_has_restrictions(self):
+ """
+ A storage category has restrictions when it:
+ - does not accept mixed products
+ - or does not accept mixed lots
+ - or do have a maximum height set on its category
+ - or do have a maximum weight set on its category
+ """
+ for rec in self:
+ rec.has_restrictions = any(
+ [
+ rec.allow_new_product != "mixed",
+ any(
+ rule.allow_new_product != "mixed"
+ for rule in rec.allow_new_product_ids
+ ),
+ rec.max_height,
+ rec.max_weight,
+ ]
+ )
+
+ def _get_product_location_domain(self, products):
+ """
+ Helper to get products location domain
+ """
+ return [
+ "|",
+ # Ideally, we would like a domain which is a strict comparison:
+ # if we do not mix products, we should be able to filter on ==
+ # product.id. Here, if we can create a move for product B and
+ # set it's destination in a location already used by product A,
+ # then all the new moves for product B will be allowed in the
+ # location.
+ ("location_will_contain_product_ids", "in", products.ids),
+ ("location_will_contain_product_ids", "=", False),
+ ]
+
+ def _domain_location_storage_category(
+ self, candidate_locations, quants, products, package_type
+ ):
+ """
+ Compute a domain which applies the constraint of the
+ Stock Storage Category to select locations among candidate locations.
+ """
+ self.ensure_one()
+ location_domain = [
+ ("id", "in", candidate_locations.ids),
+ ("computed_storage_category_id", "in", self.ids),
+ ]
+ # Build the domain using the 'allow_new_product' field
+ allow_new_product = self.get_allow_new_product(
+ product=products,
+ package_type=package_type,
+ package=quants.package_id,
+ quants=quants,
+ )
+ if allow_new_product == "empty":
+ location_domain.append(("location_is_empty", "=", True))
+ elif allow_new_product == "same":
+ location_domain += self._get_product_location_domain(products)
+ elif allow_new_product == "same_lot":
+ lots = quants.mapped("lot_id")
+ # As same lot should filter also on same product
+ location_domain += self._get_product_location_domain(products)
+ location_domain += [
+ "|",
+ # same comment as for the products
+ ("location_will_contain_lot_ids", "in", lots.ids),
+ ("location_will_contain_lot_ids", "=", False),
+ ]
+ return location_domain
+
+ def get_allow_new_product(
+ self,
+ product,
+ package_type=None,
+ package=None,
+ quants=None,
+ ):
+ """Return the `allow_new_product` option value.
+
+ It first evaluates the conditions based on different criteria, and if no
+ value can be found among them it fallbacks on the category option value.
+ """
+ self.ensure_one()
+ for rule in self.allow_new_product_ids:
+ res = True
+ for condition in rule.condition_ids:
+ res = condition.evaluate(
+ self,
+ product,
+ package_type,
+ package,
+ quants,
+ )
+ if not res:
+ # Fallback on category option value
+ return self.allow_new_product
+ # All conditions are matching
+ if res:
+ return rule.allow_new_product
+ # Fallback on category option value
+ return self.allow_new_product
diff --git a/stock_storage_type/models/stock_storage_category_allow_new_product.py b/stock_storage_type/models/stock_storage_category_allow_new_product.py
new file mode 100644
index 00000000000..fa2f1ac004f
--- /dev/null
+++ b/stock_storage_type/models/stock_storage_category_allow_new_product.py
@@ -0,0 +1,31 @@
+# Copyright 2025 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from odoo import fields, models
+
+
+class StockStorageCategoryAllowNewProduct(models.Model):
+ _name = "stock.storage.category.allow_new_product"
+ _description = "Storage Category Allow New Product Rule"
+ _order = "storage_category_id, sequence"
+
+ def _selection_allow_new_product(self):
+ return self.env["stock.storage.category"]._fields["allow_new_product"].selection
+
+ storage_category_id = fields.Many2one(
+ comodel_name="stock.storage.category",
+ ondelete="cascade",
+ required=True,
+ index=True,
+ )
+ condition_ids = fields.Many2many(
+ comodel_name="stock.storage.category.allow_new_product.cond",
+ relation="stock_storage_category_allow_new_product_cond_rel",
+ string="Conditions",
+ required=True,
+ help="All conditions have to match to apply the Allow New Product policy.",
+ )
+ allow_new_product = fields.Selection(
+ selection=_selection_allow_new_product, default="mixed", required=True
+ )
+ sequence = fields.Integer(index=True)
diff --git a/stock_storage_type/models/stock_storage_category_allow_new_product_cond.py b/stock_storage_type/models/stock_storage_category_allow_new_product_cond.py
new file mode 100644
index 00000000000..50a105c9b1e
--- /dev/null
+++ b/stock_storage_type/models/stock_storage_category_allow_new_product_cond.py
@@ -0,0 +1,138 @@
+# Copyright 2025 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+import logging
+
+from odoo import _, exceptions, models
+from odoo.tools import safe_eval
+
+_logger = logging.getLogger(__name__)
+
+
+class StockStorageCategoryAllowNewProductCond(models.Model):
+ _inherit = "stock.storage.condition.mixin"
+ _name = "stock.storage.category.allow_new_product.cond"
+ _description = "Stock Storage Category Allow New Product Condition"
+
+ _sql_constraints = [
+ (
+ "name",
+ "EXCLUDE (name WITH =) WHERE (active = True)",
+ "Storage Category Allow New Product Condition name must be unique",
+ )
+ ]
+
+ def _default_code_snippet_docs(self):
+ return """
+ Available vars:
+ * condition (recordset)
+ * storage_category (recordset)
+ * product (recordset)
+ * package_type (recordset)
+ * package (recordset)
+ * quants (recordset)
+ * env
+ * datetime
+ * dateutil
+ * time
+ * user
+ * exceptions
+
+ Must initialize a boolean 'result' variable set to True when condition is met
+
+ """
+
+ def _get_code_snippet_eval_context(
+ self,
+ storage_category,
+ product,
+ package_type,
+ package,
+ quants,
+ ):
+ """Prepare the context used when evaluating python code
+
+ :returns: dict -- evaluation context given to safe_eval
+ """
+ self.ensure_one()
+ return {
+ "env": self.env,
+ "user": self.env.user,
+ "condition": self,
+ "storage_category": storage_category,
+ "product": product,
+ "package_type": package_type,
+ "package": package,
+ "quants": quants,
+ "datetime": safe_eval.datetime,
+ "dateutil": safe_eval.dateutil,
+ "time": safe_eval.time,
+ "exceptions": safe_eval.wrap_module(
+ exceptions, ["UserError", "ValidationError"]
+ ),
+ }
+
+ def _exec_code(
+ self,
+ storage_category,
+ product,
+ package_type,
+ package,
+ quants,
+ ):
+ self.ensure_one()
+ if not self._code_snippet_valued():
+ return False
+ eval_ctx = self._get_code_snippet_eval_context(
+ storage_category,
+ product,
+ package_type,
+ package,
+ quants,
+ )
+ snippet = self.code_snippet
+ safe_eval.safe_eval(snippet, eval_ctx, mode="exec", nocopy=True)
+ result = eval_ctx.get("result")
+ if not isinstance(result, bool):
+ raise exceptions.UserError(
+ _("code_snippet should return boolean value into `result` variable.")
+ )
+ if not result:
+ _logger.debug(
+ "Condition %s not met:\n"
+ "* storage_category: %s\n"
+ "* product: %s\n"
+ "* package_type: %s\n"
+ "* package: %s\n"
+ "* quants: %s\n"
+ % (
+ self.name,
+ storage_category.ids,
+ package_type and package_type.id or None,
+ package and package.id or None,
+ product.id,
+ quants and quants.ids or None,
+ )
+ )
+ return result
+
+ def evaluate(
+ self,
+ storage_category,
+ product,
+ package_type,
+ package,
+ quants,
+ ):
+ self.ensure_one()
+ if self.condition_type == "code":
+ return self._exec_code(
+ storage_category,
+ product,
+ package_type,
+ package,
+ quants,
+ )
+ condition_type = self.condition_type
+ raise exceptions.UserError(
+ _(f"Not able to evaluate condition of type {condition_type}")
+ )
diff --git a/stock_storage_type/models/stock_storage_category_capacity.py b/stock_storage_type/models/stock_storage_category_capacity.py
deleted file mode 100644
index e007d398bf1..00000000000
--- a/stock_storage_type/models/stock_storage_category_capacity.py
+++ /dev/null
@@ -1,111 +0,0 @@
-# Copyright 2022 ACSONE SA
-# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
-from odoo import _, api, fields, models
-
-
-class StorageCategoryProductCapacity(models.Model):
-
- _inherit = "stock.storage.category.capacity"
-
- allow_new_product = fields.Selection(
- selection=[
- ("empty", "If the location is empty"),
- ("same", "If all products are same"),
- ("mixed", "Allow mixed products"),
- ("same_lot", "If all lots are the same"),
- ],
- default="mixed",
- required=True,
- )
- computed_location_ids = fields.One2many(
- comodel_name="stock.location",
- related="storage_category_id.computed_location_ids",
- )
- has_restrictions = fields.Boolean(
- compute="_compute_has_restrictions",
- help="Technical: This is used to check if we need to display warning message",
- )
-
- @api.model
- def _get_display_name_attributes(self):
- """
- Adds the storage capacity attributes to compose the display name
- """
- attributes = super()._get_display_name_attributes()
- value = self._fields["allow_new_product"].convert_to_export(
- self.allow_new_product, self
- )
- attributes.append(_("Allow New Product: ") + value)
- return attributes
-
- @api.model
- def _compute_display_name_depends(self):
- depends = super()._compute_display_name_depends()
- depends.append("allow_new_product")
- return depends
-
- @api.depends(
- "allow_new_product",
- "storage_category_id.max_height",
- "storage_category_id.max_weight",
- )
- def _compute_has_restrictions(self):
- """
- A storage capacity has restrictions when it:
- - does not accept mixed products
- - or does not accept mixed lots
- - or do have a maximum height set on its category
- - or do have a maximum weight set on its category
- """
- for capacity in self:
- capacity.has_restrictions = any(
- [
- capacity.allow_new_product != "mixed",
- capacity.storage_category_id.max_height,
- capacity.storage_category_id.max_weight,
- ]
- )
-
- def _get_product_location_domain(self, products):
- """
- Helper to get products location domain
- """
- return [
- "|",
- # Ideally, we would like a domain which is a strict comparison:
- # if we do not mix products, we should be able to filter on ==
- # product.id. Here, if we can create a move for product B and
- # set it's destination in a location already used by product A,
- # then all the new moves for product B will be allowed in the
- # location.
- ("location_will_contain_product_ids", "in", products.ids),
- ("location_will_contain_product_ids", "=", False),
- ]
-
- def _domain_location_storage_type(self, candidate_locations, quants, products):
- """
- Compute a domain which applies the constraint of the
- Stock Storage Category Capacities to select locations among candidate
- locations.
- """
- self.ensure_one()
- location_domain = [
- ("id", "in", candidate_locations.ids),
- ("computed_storage_category_id.capacity_ids", "in", self.ids),
- ]
- # Build the domain using the 'allow_new_product' field
- if self.allow_new_product == "empty":
- location_domain.append(("location_is_empty", "=", True))
- elif self.allow_new_product == "same":
- location_domain += self._get_product_location_domain(products)
- elif self.allow_new_product == "same_lot":
- lots = quants.mapped("lot_id")
- # As same lot should filter also on same product
- location_domain += self._get_product_location_domain(products)
- location_domain += [
- "|",
- # same comment as for the products
- ("location_will_contain_lot_ids", "in", lots.ids),
- ("location_will_contain_lot_ids", "=", False),
- ]
- return location_domain
diff --git a/stock_storage_type/models/stock_storage_condition_mixin.py b/stock_storage_type/models/stock_storage_condition_mixin.py
new file mode 100644
index 00000000000..aa7b7d11e3c
--- /dev/null
+++ b/stock_storage_type/models/stock_storage_condition_mixin.py
@@ -0,0 +1,65 @@
+# Copyright 2025 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+import textwrap
+
+from odoo import _, api, exceptions, fields, models
+
+
+class StockStorageConditionMixin(models.AbstractModel):
+ _name = "stock.storage.condition.mixin"
+ _description = "Mixin to implement storage condition."
+
+ name = fields.Char(required=True)
+ condition_type = fields.Selection(
+ selection=[("code", "Execute code")], default="code", required=True
+ )
+ code_snippet = fields.Text(required=True)
+ code_snippet_docs = fields.Text(
+ compute="_compute_code_snippet_docs",
+ default=lambda self: self._default_code_snippet_docs(),
+ )
+ active = fields.Boolean(default=True)
+
+ @api.constrains("condition_type", "code_snippet")
+ def _check_condition_type_code(self):
+ for rec in self.filtered(lambda c: c.condition_type == "code"):
+ if not rec._code_snippet_valued():
+ raise exceptions.UserError(
+ _(
+ "Condition type is set to `Code`: you must provide a piece of code"
+ )
+ )
+
+ def _code_snippet_valued(self):
+ self.ensure_one()
+ snippet = self.code_snippet or ""
+ return bool(
+ [
+ not line.startswith("#")
+ for line in (snippet.splitlines())
+ if line.strip("")
+ ]
+ )
+
+ def _compute_code_snippet_docs(self):
+ for rec in self:
+ rec.code_snippet_docs = textwrap.dedent(rec._default_code_snippet_docs())
+
+ def _default_code_snippet_docs(self):
+ """Return the documentation (e.g. available variables) for `code_snippet`."""
+ raise NotImplementedError
+
+ def _get_code_snippet_eval_context(self, *args, **kwargs):
+ """Prepare the context used when evaluating python code
+
+ :returns: dict -- evaluation context given to safe_eval
+ """
+ raise NotImplementedError
+
+ def _exec_code(self, *args, **kwargs):
+ raise NotImplementedError
+
+ def evaluate(self, *args, **kwargs):
+ """Evaluate and return the result of the condition."""
+ raise NotImplementedError
diff --git a/stock_storage_type/models/stock_storage_location_sequence.py b/stock_storage_type/models/stock_storage_location_sequence.py
index f789317c1f8..1dabc98d14a 100644
--- a/stock_storage_type/models/stock_storage_location_sequence.py
+++ b/stock_storage_type/models/stock_storage_location_sequence.py
@@ -6,7 +6,7 @@
class StockStorageLocationSequence(models.Model):
_name = "stock.storage.location.sequence"
- _description = "Sequence of locations to put-away the package storage type"
+ _description = "Sequence of locations to put-away the package type"
_order = "sequence"
package_type_id = fields.Many2one("stock.package.type", required=True)
@@ -22,9 +22,10 @@ class StockStorageLocationSequence(models.Model):
string="Conditions",
comodel_name="stock.storage.location.sequence.cond",
relation="stock_location_sequence_cond_rel",
+ help="All conditions have to match to apply the put-away strategy.",
)
- def _format_package_storage_type_message(self, last=False):
+ def _format_package_type_message(self, last=False):
self.ensure_one()
# TODO improve ugly code
type_matching_locations = self.location_id.get_storage_locations().filtered(
@@ -47,25 +48,25 @@ def _format_package_storage_type_message(self, last=False):
)
if last:
# If last, we want to check if restrictions are defined on
- # location storage types accepting this package storage type
+ # capacities accepting this package type
# TODO improve ugly code
capacities = type_matching_locations.mapped(
"computed_storage_category_id.capacity_ids"
).filtered(
lambda lst, package_type=self.package_type_id: package_type
== lst.package_type_id
- and not lst.has_restrictions
+ and not lst.storage_category_id.has_restrictions
)
if not capacities:
msg = _(
' * {location} (WARNING: '
- "restrictions are active on location storage types "
- "matching this package storage type)"
+ "restrictions are active on storage categories "
+ "matching this package type)"
).format(location=self.location_id.name)
else:
msg = _(
' * {location} '
- "(WARNING: no suitable location matching storage type)"
+ "(WARNING: no suitable location matching package type)"
).format(location=self.location_id.name)
return msg
diff --git a/stock_storage_type/models/stock_storage_location_sequence_cond.py b/stock_storage_type/models/stock_storage_location_sequence_cond.py
index 4a66115f119..f155c2048db 100644
--- a/stock_storage_type/models/stock_storage_location_sequence_cond.py
+++ b/stock_storage_type/models/stock_storage_location_sequence_cond.py
@@ -1,34 +1,18 @@
# Copyright 2022 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
-import textwrap
-from odoo import _, api, exceptions, fields, models
+from odoo import _, exceptions, models
from odoo.tools import safe_eval
_logger = logging.getLogger(__name__)
class StockStorageLocationSequenceCond(models.Model):
-
+ _inherit = "stock.storage.condition.mixin"
_name = "stock.storage.location.sequence.cond"
_description = "Stock Storage Location Sequence Condition"
- name = fields.Char(required=True)
-
- condition_type = fields.Selection(
- selection=[("code", "Execute code")], default="code", required=True
- )
- code_snippet = fields.Text(required=True)
- code_snippet_docs = fields.Text(
- compute="_compute_code_snippet_docs",
- default=lambda self: self._default_code_snippet_docs(),
- )
-
- active = fields.Boolean(
- default=True,
- )
-
_sql_constraints = [
(
"name",
@@ -37,20 +21,6 @@ class StockStorageLocationSequenceCond(models.Model):
)
]
- def _compute_code_snippet_docs(self):
- for rec in self:
- rec.code_snippet_docs = textwrap.dedent(rec._default_code_snippet_docs())
-
- @api.constrains("condition_type", "code_snippet")
- def _check_condition_type_code(self):
- for rec in self.filtered(lambda c: c.condition_type == "code"):
- if not rec._code_snippet_valued():
- raise exceptions.UserError(
- _(
- "Condition type is set to `Code`: you must provide a piece of code"
- )
- )
-
def _default_code_snippet_docs(self):
return """
Available vars:
@@ -124,17 +94,6 @@ def _exec_code(self, storage_location_sequence, putaway_location, quant, product
)
return result
- def _code_snippet_valued(self):
- self.ensure_one()
- snippet = self.code_snippet or ""
- return bool(
- [
- not line.startswith("#")
- for line in (snippet.splitlines())
- if line.strip("")
- ]
- )
-
def evaluate(self, storage_location_sequence, putaway_location, quant, product):
self.ensure_one()
if self.condition_type == "code":
diff --git a/stock_storage_type/readme/CONFIGURATION.rst b/stock_storage_type/readme/CONFIGURATION.rst
index 00a0c3f399c..b16e7a780fd 100644
--- a/stock_storage_type/readme/CONFIGURATION.rst
+++ b/stock_storage_type/readme/CONFIGURATION.rst
@@ -1,11 +1,11 @@
-Got to "Inventory > Settings > Storage Types", to define Package Storage Types
-and Location Storage Types.
+Go to "Inventory > Settings > Package Types" and
+"Inventory > Settings > Storage Categories", to define Package Types and Storage
+Categories.
-Package Storage Type can be defined on Product Packaging form view from the
-product form view.
+Package Type can be set on Product and Product Packaging.
-Location Storage Type can be added to any stock location and will be computed
-automatically as Allowed Locations Storage Types on said stock location's
+Storage Category can be added to any stock location and will be computed
+automatically as Allowed Storage Category on said stock location's
children location.
@@ -13,16 +13,16 @@ children location.
On stock locations, you can define a "Pack put-away strategy" as "Ordered bins",
so that any move, having this locations as its destination, will be put-away
-on a children location, according to the restrictions from storage types.
+on a children location, according to the restrictions from package types.
- Put-away sequence
-For any package storage types, you must define a Put-away sequence (i.e. stock
+For any package types, you must define a Put-away sequence (i.e. stock
location to search) where such a package is allowed to be put-away. Locations
will be processed sequentially and the first one having an allowed child
location (according to restrictions) will be used to put away.
-A good practice here, is to set a location accepting this storage type without
+A good practice here, is to set a location accepting this package type without
any restriction as the last location in the sequence, to act as a fallback
if no other location could be found before.
diff --git a/stock_storage_type/readme/DESCRIPTION.rst b/stock_storage_type/readme/DESCRIPTION.rst
index bd8d800bb2a..ecd00d730a4 100644
--- a/stock_storage_type/readme/DESCRIPTION.rst
+++ b/stock_storage_type/readme/DESCRIPTION.rst
@@ -1,51 +1,33 @@
-This module introduces two new models in order to manage stock moves with
- packages according to the packaging and stock location properties.
+This module extends package types Odoo feature in order to better manage stock
+moves with packages according to the packaging and stock location properties
+(like height, weight or any customized conditions).
-* Stock package storage type (`stock.package.storage.type`)
+Moreover, this module implements "package type put-away strategy" in order to
+compute a put-away location using package types.
- This model is linked to product.packaging and defines the type of storage
- related to a specific packaging.
-
-* Stock location storage type (`stock.location.storage.type`)
-
- This models is linked to stock.location and defines the types of storage
- that are allowed for a specific location.
-
-Therefore a Stock location storage type can include different Stock package
-storage type in order to validate the destination of a move with package into a
-stock location.
-Moreover Stock location storage type can include product, size or lot
-restrictions for the stock locations it's defined on, so that a move with
-package will only be allowed if it doesn't violate the restrictions defined
-(cf stock_location_storage_type_strategy).
-
-Moreover, this module implements "storage type put-away strategy" in order to compute a
-put-away location using storage types.
-
-The standard put-away strategy is applied *before* the storage type put-away
+The standard put-away strategy is applied *before* the package type put-away
strategy as the former relies on product or product category and the latter
relies on stock packages.
-In other words, when a move is assigned, Odoo standard put-away strategy will be
+In other words, when a move is reserved, Odoo standard put-away strategy will be
applied to compute a new destination on the stock move lines, according to the
product.
-After this first "put-away computation", the "storage type" put-away strategy
-is applied, if the reserved quant is linked to a package defining a package
-storage type.
+After this first "put-away computation", the "package type" put-away strategy
+is applied, if the reserved quant is linked to a package defining a package type.
-Storage locations linked to the package storage are processed sequentially, if
+Storage locations linked to the package type are processed sequentially, if
said storage location is a child of the move line's destination location (i.e
either the put-away location or the move's destination location).
-For each location, their packs storage strategy is applied as well as the
-restrictions defined on the stock location storage types.
+For each location, their package type strategy is applied as well as the
+restrictions defined on the storage category.
If no suitable location is found, the next location in the sequence will be
searched and so on.
-For the packs putaway strategy "none", the location is considered as is. For
+For the package type putaway strategy "None", the location is considered as is. For
the "ordered children" strategy, children locations are sorted by first by max
height which is a physical constraint to respect, then pack putaway sequence
which allow to favor for example some level or corridor, and finally by name.
At the end, if found location is not the same as the original destination location,
-the putaway strategies are applied (e.g.: A "none" pack putaway strategy is set on
+the putaway strategies are applied (e.g.: A "None" pack putaway strategy is set on
computed location and a putaway rule exists on that one).
diff --git a/stock_storage_type/security/ir.model.access.csv b/stock_storage_type/security/ir.model.access.csv
index ed6a03817a1..7fa2b5e7312 100644
--- a/stock_storage_type/security/ir.model.access.csv
+++ b/stock_storage_type/security/ir.model.access.csv
@@ -3,3 +3,7 @@ access_stock_storage_location_sequence_user,access_stock_storage_location_sequen
access_stock_storage_location_sequence_manager,access_stock_storage_location_sequence_manager,model_stock_storage_location_sequence,stock.group_stock_manager,1,1,1,1
access_stock_storage_location_sequence_cond_user,access_stock_storage_location_sequence_cond_user,model_stock_storage_location_sequence_cond,base.group_user,1,0,0,0
access_stock_storage_location_sequence_cond_manager,access_stock_storage_location_sequence_cond_manager,model_stock_storage_location_sequence_cond,stock.group_stock_manager,1,1,1,1
+access_stock_storage_category_allow_new_product_user,access_stock_storage_category_allow_new_product_user,model_stock_storage_category_allow_new_product,base.group_user,1,0,0,0
+access_stock_storage_category_allow_new_product_manager,access_stock_storage_category_allow_new_product_manager,model_stock_storage_category_allow_new_product,stock.group_stock_manager,1,1,1,1
+access_stock_storage_category_allow_new_product_cond_user,access_stock_storage_category_allow_new_product_cond_user,model_stock_storage_category_allow_new_product_cond,base.group_user,1,0,0,0
+access_stock_storage_category_allow_new_product_cond_manager,access_stock_storage_category_allow_new_product_cond_manager,model_stock_storage_category_allow_new_product_cond,stock.group_stock_manager,1,1,1,1
diff --git a/stock_storage_type/tests/__init__.py b/stock_storage_type/tests/__init__.py
index 06655c55be3..ba7aa132776 100644
--- a/stock_storage_type/tests/__init__.py
+++ b/stock_storage_type/tests/__init__.py
@@ -6,4 +6,5 @@
test_storage_type,
test_storage_type_move,
test_storage_type_putaway_strategy,
+ test_storage_category_allow_new_product,
)
diff --git a/stock_storage_type/tests/test_auto_assign_storage_type.py b/stock_storage_type/tests/test_auto_assign_storage_type.py
index bd83034e14a..f80562ca2af 100644
--- a/stock_storage_type/tests/test_auto_assign_storage_type.py
+++ b/stock_storage_type/tests/test_auto_assign_storage_type.py
@@ -19,7 +19,7 @@ def setUpClass(cls):
def test_auto_assign_package_storage_type_without_packaging_id(self):
"""Packages without `packaging_id` are internal packages and they
are intended to be stored in the warehouse.
- On such packages storage type is automatically defined.
+ On such packages, a package type is automatically defined.
"""
package = self.env["stock.quant.package"].create(
{"name": "TEST", "product_packaging_id": self.product_packaging.id}
diff --git a/stock_storage_type/tests/test_stock_location.py b/stock_storage_type/tests/test_stock_location.py
index 370f3295d95..6381ee18195 100644
--- a/stock_storage_type/tests/test_stock_location.py
+++ b/stock_storage_type/tests/test_stock_location.py
@@ -50,7 +50,7 @@ def test_get_ordered_leaf_locations(self):
| self.pallets_reserve_bin_4_location
).ids,
)
- # Set the max_height on pallets storage type higher than the others
+ # Set the max_height on pallets storage category higher than the others
self.pallets_location_storage_type.storage_category_id.max_height = 2
self.cardboxes_location_storage_type.storage_category_id.max_height = 1
ordered_locations = sublocation.get_storage_locations(self.product)
@@ -71,7 +71,7 @@ def test_get_ordered_leaf_locations(self):
| self.pallets_reserve_bin_4_location
).ids,
)
- # Set the max_height on cardboxes storage type higher than the others
+ # Set the max_height on cardboxes storage category higher than the others
self.pallets_location_storage_type.storage_category_id.max_height = 1
self.cardboxes_location_storage_type.storage_category_id.max_height = 2
ordered_locations = sublocation.get_storage_locations(self.product)
@@ -283,11 +283,12 @@ def test_location_is_empty(self):
self._update_qty_in_location(location, self.product, 10)
self.assertFalse(location.location_is_empty)
- # When the location has no "only_empty" storage type, we don't
+ # When the location has no "only_empty" rule, we don't
# care about if it is empty or not, we keep it as True so we
# can always put things inside. Not computing it prevents
# useless race conditions on concurrent writes.
- location.computed_storage_category_id.capacity_ids.filtered(
- lambda c: c.allow_new_product == "empty"
+ category = location.computed_storage_category_id
+ category.allow_new_product_ids.filtered(
+ lambda rule: rule.allow_new_product == "empty"
).allow_new_product = "mixed"
self.assertTrue(location.location_is_empty)
diff --git a/stock_storage_type/tests/test_storage_category_allow_new_product.py b/stock_storage_type/tests/test_storage_category_allow_new_product.py
new file mode 100644
index 00000000000..6a890113783
--- /dev/null
+++ b/stock_storage_type/tests/test_storage_category_allow_new_product.py
@@ -0,0 +1,111 @@
+# Copyright 2019 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
+from .common import TestStorageTypeCommon
+
+
+class TestStorageCategoryAllowNewProduct(TestStorageTypeCommon):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.areas.write({"pack_putaway_strategy": "ordered_locations"})
+ cls.category = cls.pallets_location_storage_type.storage_category_id
+ # Configure the rule matching Pallets to allow the same product on locations
+ cls.category.allow_new_product_ids.allow_new_product = "same"
+
+ def test_storage_category_allow_new_product(self):
+ self.category.allow_new_product = "empty"
+ self.assertEqual(self.category.get_allow_new_product(self.product), "empty")
+ self.category.allow_new_product = "same_lot"
+ self.assertEqual(self.category.get_allow_new_product(self.product), "same_lot")
+ # Create a quant with a package of type Pallet to check the
+ # allow_new_product rule result
+ package_type_pallets = self.env.ref(
+ "stock_storage_type.package_storage_type_pallets"
+ )
+ package = self.env["stock.quant.package"].create(
+ {
+ "name": "TEST PKG",
+ "package_type_id": package_type_pallets.id,
+ }
+ )
+ self.env["stock.quant"]._update_available_quantity(
+ self.product,
+ self.pallets_bin_2_location,
+ 1.0,
+ package_id=package,
+ )
+ quant = self.env["stock.quant"].search(
+ [
+ ("location_id", "=", self.pallets_bin_2_location.id),
+ ("product_id", "=", self.product.id),
+ ("package_id", "=", package.id),
+ ]
+ )
+ self.assertEqual(
+ self.category.get_allow_new_product(
+ self.product,
+ quants=quant,
+ package_type=package_type_pallets,
+ package=package,
+ ),
+ "same",
+ )
+
+ def test_storage_strategy_with_allow_new_product_rule(self):
+ # Set pallets location type as only empty, while it also has a rule
+ # that will force the 'allow_new_product' to 'same'
+ self.pallets_location_storage_type.storage_category_id.write(
+ {"allow_new_product": "empty"}
+ )
+ # Create picking
+ in_picking = self.env["stock.picking"].create(
+ {
+ "picking_type_id": self.receipts_picking_type.id,
+ "location_id": self.suppliers_location.id,
+ "location_dest_id": self.input_location.id,
+ "move_ids": [
+ (
+ 0,
+ 0,
+ {
+ "name": self.product.name,
+ "location_id": self.suppliers_location.id,
+ "location_dest_id": self.input_location.id,
+ "product_id": self.product.id,
+ "product_uom_qty": 96.0,
+ "product_uom": self.product.uom_id.id,
+ },
+ )
+ ],
+ }
+ )
+ # Mark as todo
+ in_picking.action_confirm()
+ # Put in pack
+ in_picking.move_line_ids.qty_done = 48.0
+ first_package = in_picking.action_put_in_pack()
+ # Ensure packaging is set properly on pack
+ first_package.product_packaging_id = self.product_pallet_product_packaging
+ # Put in pack again
+ ml_without_package = in_picking.move_line_ids.filtered(
+ lambda ml: not ml.result_package_id
+ )
+ ml_without_package.qty_done = 48.0
+ second_pack = in_picking.action_put_in_pack()
+ # Ensure packaging is set properly on pack
+ second_pack.product_packaging_id = self.product_pallet_product_packaging
+
+ # Validate picking
+ in_picking.button_validate()
+ # Assign internal picking
+ int_picking = in_picking.move_ids.move_dest_ids.picking_id
+ int_picking.action_assign()
+ self.assertEqual(int_picking.location_dest_id, self.stock_location)
+ self.assertEqual(
+ int_picking.move_ids.mapped("location_dest_id"), self.stock_location
+ )
+ # First & second move lines goes into pallets bin 1, as forced by the rule
+ self.assertEqual(
+ int_picking.move_line_ids.mapped("location_dest_id"),
+ self.pallets_bin_1_location,
+ )
diff --git a/stock_storage_type/tests/test_storage_type.py b/stock_storage_type/tests/test_storage_type.py
index a7747c71eda..aa6be654e5e 100644
--- a/stock_storage_type/tests/test_storage_type.py
+++ b/stock_storage_type/tests/test_storage_type.py
@@ -37,7 +37,7 @@ def setUpClass(cls):
)
def test_location_allowed_storage_types(self):
- # As cardboxes location storage type is defined on parent stock
+ # As cardboxes capacity is defined on parent stock
# location_storage_type_ids
self.assertEqual(
self.cardboxes_stock.computed_storage_category_id.capacity_ids,
@@ -89,7 +89,7 @@ def test_location_allowed_storage_types(self):
self.cardboxes_location_storage_type,
)
# If I create a child bin on cardboxes bin 1, it will use the first
- # parent's storage type
+ # parent's capacity
bin_1_child = self.env["stock.location"].create(
{"name": "Carboxes bin 1 child", "location_id": self.cardboxes_bin_1.id}
)
@@ -163,16 +163,23 @@ def test_package_message(self):
Test for the message displayed on Stock Package Type forms
"""
pallets = self.env.ref("stock_storage_type.package_storage_type_pallets")
- message = "When a package with storage type Pallets is put away, the "
+ category = pallets.storage_category_capacity_ids.storage_category_id
+ message = "When a package with type Pallets is put away, the "
message += "strategy will look for an allowed location in the "
message += "following locations:"
self.assertIn(message, pallets.storage_type_message)
+ category.allow_new_product_ids.allow_new_product = "empty"
+ pallets._compute_storage_type_message()
message = (
"Pallets reserve storage area (WARNING: restrictions are active on "
- "location storage types matching this package storage type)"
+ "storage categories matching this package type)"
)
+ self.assertIn(message, pallets.storage_type_message)
+ category.allow_new_product_ids.allow_new_product = "mixed"
+ pallets._compute_storage_type_message()
+ message = "Pallets reserve storage area (Ordered Children Locations)"
self.assertIn(message, pallets.storage_type_message)
def test_sequence_to_location_menu(self):
@@ -185,9 +192,3 @@ def test_sequence_to_location_menu(self):
),
action["domain"],
)
-
- def test_storage_capacity_display(self):
- self.assertEqual(
- self.cardboxes_stock.computed_storage_category_id.capacity_ids.display_name,
- "Cardboxes x 1.0 (Package: Cardboxes - Allow New Product: Allow mixed products)",
- )
diff --git a/stock_storage_type/tests/test_storage_type_move.py b/stock_storage_type/tests/test_storage_type_move.py
index 86e5d1aa7b7..1915122a3a6 100644
--- a/stock_storage_type/tests/test_storage_type_move.py
+++ b/stock_storage_type/tests/test_storage_type_move.py
@@ -38,28 +38,33 @@ def _test_confirmed_move(self, product=None):
return move_to_assign
def test_not_only_empty_confirmed_move(self):
- self.pallets_location_storage_type.write({"allow_new_product": "mixed"})
+ category = self.pallets_location_storage_type.storage_category_id
+ category.allow_new_product_ids.allow_new_product = "mixed"
move = self._test_confirmed_move()
self.assertEqual(
move.move_line_ids.location_dest_id, self.pallets_bin_1_location
)
def test_only_empty_confirmed_move(self):
- self.pallets_location_storage_type.write({"allow_new_product": "empty"})
+ category = self.pallets_location_storage_type.storage_category_id
+ category.allow_new_product_ids.allow_new_product = "empty"
move = self._test_confirmed_move()
self.assertNotEqual(
move.move_line_ids.location_dest_id, self.pallets_bin_1_location
)
def test_do_not_mix_products_confirmed_move_ok(self):
- self.pallets_location_storage_type.write({"allow_new_product": "same"})
+ category = self.pallets_location_storage_type.storage_category_id
+ category.allow_new_product_ids.allow_new_product = "same"
move = self._test_confirmed_move()
self.assertEqual(
move.move_line_ids.location_dest_id, self.pallets_bin_1_location
)
def test_do_not_mix_products_confirmed_move_nok(self):
- self.pallets_location_storage_type.write({"allow_new_product": "same"})
+ self.pallets_location_storage_type.storage_category_id.write(
+ {"allow_new_product": "same"}
+ )
move_other_product = self._test_confirmed_move(
self.env.ref("product.product_product_10")
)
@@ -70,7 +75,9 @@ def test_do_not_mix_products_confirmed_move_nok(self):
def test_package_level_location_dest_domain_only_empty(self):
# Set pallets location type as only empty
- self.pallets_location_storage_type.write({"allow_new_product": "empty"})
+ self.pallets_location_storage_type.storage_category_id.write(
+ {"allow_new_product": "empty"}
+ )
# Create picking
in_picking = self.env["stock.picking"].create(
{
@@ -202,7 +209,9 @@ def test_package_level_location_dest_domain_mixed(self):
# Mark picking to allow creation and use of existing lots in order
# to register two times the same lot in different packages
self.receipts_picking_type.use_existing_lots = True
- self.cardboxes_location_storage_type.write({"allow_new_product": "same_lot"})
+ self.cardboxes_location_storage_type.storage_category_id.write(
+ {"allow_new_product": "same_lot"}
+ )
# Create picking
in_picking = self.env["stock.picking"].create(
{
@@ -381,7 +390,9 @@ def test_stock_move_no_package(self):
Check that lot restriction is well applied
"""
# Constrain Cardbox Capacity to accept same lots only
- self.cardboxes_location_storage_type.write({"allow_new_product": "same_lot"})
+ self.cardboxes_location_storage_type.storage_category_id.write(
+ {"allow_new_product": "same_lot"}
+ )
# Set a quantity in cardbox bin 2 to make sure constraint is applied
self.env["stock.quant"]._update_available_quantity(
self.env.ref("product.product_product_10"),
diff --git a/stock_storage_type/tests/test_storage_type_putaway_strategy.py b/stock_storage_type/tests/test_storage_type_putaway_strategy.py
index 3f2956576c6..a2e2f82b120 100644
--- a/stock_storage_type/tests/test_storage_type_putaway_strategy.py
+++ b/stock_storage_type/tests/test_storage_type_putaway_strategy.py
@@ -81,7 +81,9 @@ def test_storage_strategy_ordered_locations_cardboxes(self):
def test_storage_strategy_only_empty_ordered_locations_pallets(self):
# Set pallets location type as only empty
- self.pallets_location_storage_type.write({"allow_new_product": "empty"})
+ self.pallets_location_storage_type.storage_category_id.write(
+ {"allow_new_product": "empty"}
+ )
# Set a quantity in pallet bin 2 to make sure constraint is applied
self.env["stock.quant"]._update_available_quantity(
self.product, self.pallets_bin_2_location, 1.0
@@ -142,14 +144,25 @@ def test_storage_strategy_only_empty_ordered_locations_pallets(self):
)
def test_storage_strategy_max_weight_ordered_locations_pallets(self):
+ """Test pallet max weight constraint on a location.
+
+ Configure 'Pallets storage area/Pallets Bin 2' with a max weight to 50kg.
+ Reception of two pallets of 60kg suggests to put them into Bin 1 and 3,
+ skipping Bin 2 that doesn't match anymore.
+ """
+ self.pallets_location.storage_category_id.allow_new_product = "empty"
# Add a category for max_weight 50
category_50 = self.env["stock.storage.category"].create(
- {"name": "Pallets max 50 kg", "max_weight": 50}
+ {
+ "name": "Pallets max 50 kg",
+ "max_weight": 50,
+ "allow_new_product": "empty",
+ }
)
# Define new pallets location type with a max weight on bin 2
light_location_storage_type = self.pallets_location_storage_type.copy(
- {"allow_new_product": "empty", "storage_category_id": category_50.id}
+ {"storage_category_id": category_50.id}
)
self.pallets_bin_2_location.write({"storage_category_id": category_50.id})
self.assertEqual(
@@ -215,7 +228,9 @@ def test_storage_strategy_max_weight_ordered_locations_pallets(self):
)
def test_storage_strategy_no_products_lots_mix_ordered_locations_cardboxes(self):
- self.cardboxes_location_storage_type.write({"allow_new_product": "same_lot"})
+ self.cardboxes_location_storage_type.storage_category_id.write(
+ {"allow_new_product": "same_lot"}
+ )
# Set a quantity in cardbox bin 2 to make sure constraint is applied
self.env["stock.quant"]._update_available_quantity(
self.env.ref("product.product_product_10"),
@@ -398,7 +413,9 @@ def test_storage_strategy_do_not_mix_products_reuse_location(self):
(less qty first).
"""
StockLocation = self.env["stock.location"]
- self.cardboxes_location_storage_type.write({"allow_new_product": "same"})
+ self.cardboxes_location_storage_type.storage_category_id.write(
+ {"allow_new_product": "same"}
+ )
product = self.product
packaging = self.product_cardbox_product_packaging
dest_location = self.cardboxes_location
diff --git a/stock_storage_type/views/stock_package_type.xml b/stock_storage_type/views/stock_package_type.xml
index 7fc87b9c726..e688068d398 100644
--- a/stock_storage_type/views/stock_package_type.xml
+++ b/stock_storage_type/views/stock_package_type.xml
@@ -32,12 +32,6 @@
-
-
-
diff --git a/stock_storage_type/views/stock_storage_category.xml b/stock_storage_type/views/stock_storage_category.xml
index 4665528732a..f0ee3bb7e6e 100644
--- a/stock_storage_type/views/stock_storage_category.xml
+++ b/stock_storage_type/views/stock_storage_category.xml
@@ -1,5 +1,6 @@
@@ -7,9 +8,26 @@
stock.storage.category
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/stock_storage_type/views/stock_storage_category_allow_new_product_cond.xml b/stock_storage_type/views/stock_storage_category_allow_new_product_cond.xml
new file mode 100644
index 00000000000..b67aba6b640
--- /dev/null
+++ b/stock_storage_type/views/stock_storage_category_allow_new_product_cond.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+ stock.storage.category.allow_new_product.cond.form
+ stock.storage.category.allow_new_product.cond
+
+
+
+
+
+
+ Storage Category Allow New Product Conditions
+ stock.storage.category.allow_new_product.cond
+ tree,form
+
+
+
diff --git a/stock_storage_type/views/stock_storage_category_capacity.xml b/stock_storage_type/views/stock_storage_category_capacity.xml
deleted file mode 100644
index e7db3cead26..00000000000
--- a/stock_storage_type/views/stock_storage_category_capacity.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
- stock.storage.category.capacity.tree (in stock_storage_type)
- stock.storage.category.capacity
-
-
-
-
-
-
-
-
-
diff --git a/stock_storage_type/views/storage_type_menus.xml b/stock_storage_type/views/storage_type_menus.xml
index 8300eef602f..6ec04679ee5 100644
--- a/stock_storage_type/views/storage_type_menus.xml
+++ b/stock_storage_type/views/storage_type_menus.xml
@@ -7,6 +7,13 @@
sequence="9"
groups="stock.group_adv_location"
/>
+